<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Code on Schallbert's Blog</title><link>https://blog.schallbert.de/en/categories/code/</link><description>Recent content in Code on Schallbert's Blog</description><generator>Hugo</generator><language>en</language><lastBuildDate>Sun, 01 Nov 2026</lastBuildDate><atom:link href="https://blog.schallbert.de/en/categories/code/index.xml" rel="self" type="application/rss+xml"/><item><title>Auto-Upgrade Docker Images</title><link>https://blog.schallbert.de/en/auto-upgrade-docker-images/</link><pubDate>Sun, 01 Nov 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/auto-upgrade-docker-images/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-11-01-docker-image-update-cover.avif"&#10; class="post-cover"&#10; alt="Image: A whale carries shipping containers on its back. One of the containers is open, and a crane hook updates the containing image with new content in huge bags."&#10; title="Auto-Upgrade Docker Images" /&gt;&#10;&lt;h2 id="why-automatic-updates"&gt;Why automatic updates?&lt;/h2&gt;&#10;&lt;p&gt;Software ages. Or rather, time passes it by, meaning it requires maintenance and must be kept up to date.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Code is a liability (not an asset).&amp;rdquo; - &lt;a href="https://doctorow.medium.com/https-pluralistic-net-2026-01-06-1000x-liability-graceful-failure-modes-d69f384af9e4" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Cory Doctorow, Jan 2026&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;On my blog, I have frequently reported on issues such as critical vulnerabilities &lt;a href="https://blog.schallbert.de/en/gitea-critical-vulnerability-active-exploitation-rce/"&gt;in Gitea&lt;/a&gt; or &lt;a href="https://blog.schallbert.de/en/php2shell-critical-vulnerability-rce/"&gt;in PHP&lt;/a&gt;, resolved &lt;a href="https://blog.schallbert.de/en/gitea-out-of-memory/"&gt;system crashes&lt;/a&gt;, and recovered &lt;a href="https://blog.schallbert.de/en/repo-lookout-fix-deploy/"&gt;unintentionally published pages&lt;/a&gt;. All of which could be addressed through updates or reconfiguration.&lt;/p&gt;&#10;&lt;p&gt;My server&amp;rsquo;s operating system and the key programs running on it are already set up for automatic updates, as described in my &lt;a href="https://blog.schallbert.de/en/server-auto-upgrade/"&gt;article on server auto-upgrades&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Nevertheless, I am not quite finished with this topic.&lt;/p&gt;&#10;&lt;h2 id="updating-docker-images-with-watchtower"&gt;Updating Docker images with Watchtower&lt;/h2&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-11-01-watchtower-decomissioning.avif" alt="Image: Decomissioning watchtower. A construction crane disassembles a watchtower, its lighting tip on the hook."&gt;&lt;/figure&gt;&#10;Until now, I have used the program &lt;a href="https://github.com/containrrr/watchtower" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Watchtower&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to manage my Docker images. But there are several reasons to move on:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;It is no longer actively maintained and has been archived. However, there are &lt;a href="https://github.com/nicholas-fedor/watchtower" target="_blank" rel="noopener noreferrer" class="external-link"&gt;forks that are still being maintained&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;To function, it requires access to the Docker daemon via &lt;code&gt;docker.sock&lt;/code&gt;. This is &lt;a href="https://cheatsheetseries.owasp.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;like granting the container root access&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to my system which I want to avoid in the future.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Looking for a direct replacement for Watchtower? &lt;a href="https://linuxhandbook.com/blog/watchtower-like-docker-tools/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Linuxhandbook lists a few solutions here&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. A common drawback among them, is that they either only notify you about available updates without deploying them, or they also require &lt;code&gt;docker.sock&lt;/code&gt; to be mounted as a volume.&lt;/p&gt;&#10;&lt;h2 id="a-script-based-solution"&gt;A Script-Based Solution&lt;/h2&gt;&#10;&lt;p&gt;In search of an approach that avoids the security risk posed by &lt;code&gt;docker.sock&lt;/code&gt;, I quickly arrived at the idea of running a script daily via &lt;code&gt;cron&lt;/code&gt;. Since this script executes with &lt;code&gt;root&lt;/code&gt; privileges, it can run Docker commands directly.&lt;/p&gt;&#10;&lt;h3 id="complicated-docker-pull-comparing-against-existing-images-and-deploying-if-necessary"&gt;Complicated: &lt;code&gt;docker pull&lt;/code&gt;, comparing against existing images, and deploying if necessary&lt;/h3&gt;&#10;&lt;p&gt;My first script is 164 lines of code (&lt;code&gt;164loc&lt;/code&gt;) and performs the following steps:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Verifies that the required tools &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt;, &lt;code&gt;docker compose&lt;/code&gt;, and the &lt;code&gt;docker-compose.yml&lt;/code&gt; file-are installed and functioning correctly.&lt;/li&gt;&#10;&lt;li&gt;Identifies the current &lt;a href="https://docs.docker.com/dhi/explore/security-concepts/digests/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;image digests&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for the services defined in the &lt;code&gt;docker-compose.yml&lt;/code&gt; file.&lt;/li&gt;&#10;&lt;li&gt;Updates the registry for each service using &lt;code&gt;docker compose pull&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Compares the &amp;ldquo;new&amp;rdquo; image digests with the &amp;ldquo;old&amp;rdquo; ones using &lt;code&gt;docker image inspect&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Launches the new images via &lt;code&gt;docker compose up&lt;/code&gt; and saves the logs.&lt;/li&gt;&#10;&lt;li&gt;Sends a notification to me via &lt;a href="https://ntfy.sh/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ntfy&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; if any updates have taken place.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This approach proved too complicated. The tool I use, &lt;code&gt;docker compose&lt;/code&gt;, includes the necessary option out of the box already: &lt;a href="https://devops-daily.com/posts/docker-compose-always-recreate-containers" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;docker compose up --pull always&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="simple-include-everything-in-a-single-command-via-an-option"&gt;Simple: Include everything in a single command via an option&lt;/h3&gt;&#10;&lt;p&gt;The core of my new script (&lt;code&gt;61loc&lt;/code&gt;) looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;compose&lt;span style="color:#f92672"&gt;=(&lt;/span&gt;docker compose --progress plain -f &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$COMPOSE_FILE&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;before&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;compose[@]&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; ps --all --quiet | sort&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; output&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;compose[@]&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; up -d --pull always --quiet-pull --wait &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --wait-timeout &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$WAIT_TIMEOUT&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt;; &lt;span style="color:#66d9ef"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; status&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;else&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; status&lt;span style="color:#f92672"&gt;=&lt;/span&gt;$?&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;printf &lt;span style="color:#e6db74"&gt;&amp;#39;[%s] docker compose up (exit %s)\n%s\n\n&amp;#39;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;date -Is&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$status&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$output&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; &amp;gt;&amp;gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$CHANGELOG&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;((&lt;/span&gt; status !&lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt; &lt;span style="color:#f92672"&gt;))&lt;/span&gt;; &lt;span style="color:#66d9ef"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; report_failure &lt;span style="color:#e6db74"&gt;&amp;#34;WARNING: Docker update failed&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;warning&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$output&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; exit &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$status&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;after&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;compose[@]&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; ps --all --quiet | sort&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;[[&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$before&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; !&lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$after&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;]]&lt;/span&gt;; &lt;span style="color:#66d9ef"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; notify &lt;span style="color:#e6db74"&gt;&amp;#34;INFO: Docker containers updated&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;white_check_mark&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$output&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In the script, I capture the state prior to the update by listing all services specified in the Compose file and storing them in a variable. I then execute &lt;code&gt;docker compose up -d&lt;/code&gt; as usual, but include the flags &lt;code&gt;--pull always&lt;/code&gt;, &lt;code&gt;--quiet-pull&lt;/code&gt; and &lt;code&gt;--wait --wait-timeout &amp;lt;time[s]&amp;gt;&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The pull option prompts Docker to check the registry for new images, which are then downloaded and deployed if available in non-verbose fashion (no progress for individual download packages, etc). The timeout sets a specific duration for the command to complete before it terminates with an error, ensuring the script cannot hang indefinitely.&lt;/p&gt;&#10;&lt;p&gt;The final steps involve documentation and monitoring: the command&amp;rsquo;s output is recorded in a changelog, and in the event of a failure, I receive a notification implemented via &lt;a href="https://blog.schallbert.de/en/server-deploy-logging/#ntfy"&gt;Ntfy, as described in my post on server logging&lt;/a&gt;. This is where the initial state (captured before the update) comes into play: I only receive a notification if an error occurs or an update is performed.&lt;/p&gt;&#10;&lt;p&gt;Finally, I make the script executable per &lt;code&gt;chmod +x docker-image-update.sh&lt;/code&gt; and assign ownership to &lt;code&gt;root&lt;/code&gt; with &lt;code&gt;chown root:root docker-image-update.sh&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="cron-job-entry"&gt;Cron job entry&lt;/h2&gt;&#10;&lt;p&gt;After manually testing the script (&lt;code&gt;./docker-image-update.sh&lt;/code&gt;) and verifying its output, the changelog, and the new container versions, I configure the cron daemon to execute the script daily:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine: crontab -e&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt; * * * /path/to/docker-image-update.sh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="which-version-was-installed"&gt;Which version was installed?&lt;/h2&gt;&#10;&lt;p&gt;Instead of using service version labels, Docker relies on image digests. This &lt;code&gt;sha256-hash&lt;/code&gt; is unique to each image, but it is not human-readable:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sha256:94a00394bc5a8ef503fb59db0a7d0ae9e1110866e8aee8ba40cd864cea69ea1a &lt;span style="color:#75715e"&gt;# example Image digest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-11-01-ntfy-report-docker-image-update.avif" alt="Image: Screenshot of my phone&amp;#39;s Notify app message entry, showing an image update."&gt;&lt;/figure&gt;&#10;Service version labels such as &lt;code&gt;Gitea v28.2.1&lt;/code&gt; are not supported by default in image metadata. Consequently, I do not currently have a solution for displaying a version number. I chose to omit the digests from the output altogether.&lt;/p&gt;&#10;&lt;h2 id="notification-upon-update"&gt;Notification upon update&lt;/h2&gt;&#10;&lt;p&gt;The notification could be improved: ideally, I only need the names of the containers that were updated and confirmation that all images are &amp;ldquo;healthy&amp;rdquo; afterward. At present, I receive the full status of the containers, from which I would first need to extract the relevant information. Given that the added value of filtering this data is minimal, it is not worth the time required to expand the script. After all, the &lt;code&gt;Recreate&lt;/code&gt; process already indicates which container has been updated.&lt;/p&gt;&#10;&lt;p&gt;I am satisfied. By using just a few lines of code, relying on built-in tools and only &lt;code&gt;cron&lt;/code&gt; as an additional dependency, I have successfully solved the problem of updating containers managed via &lt;em&gt;Docker Compose&lt;/em&gt; from the host machine.&lt;/p&gt;&#10;</description></item><item><title>PHP2shell: Critical vulnerability</title><link>https://blog.schallbert.de/en/php2shell-critical-vulnerability-rce/</link><pubDate>Wed, 21 Oct 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/php2shell-critical-vulnerability-rce/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-21-php2shell-cover.avif"&#10; class="post-cover"&#10; alt="Image: The logo for php2shell. It is blue, block-like pixel text on black background."&#10; title="PHP2shell: Critical vulnerability" /&gt;&#10;&lt;h2 id="step-1-recognition-we-have-a-problem"&gt;Step 1: Recognition: We have a problem.&lt;/h2&gt;&#10;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: PHP currently has &lt;a href="https://www.php.net/archive/2026.php#2026-10-20-1" target="_blank" rel="noopener noreferrer" class="external-link"&gt;two critical vulnerabilities&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; that allow for Remote Code Execution. Their identifiers are &lt;a href="https://cert.europa.eu/publications/security-advisories/2026-018/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;CVE-2026-109618 and CVE-2026-109619&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Since the root cause lies within the engine itself, rather than the frontend, the only solution is to update to &lt;a href="https://watchtowr.com/intelligence/php-cve-2026-109618-faq/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-21-phps-php2shell-notice.avif" alt="Image: Screenshot of the PHP News Archive webpage on 21-Oct-2026 about a critical vulnerability allowing Remote Code Execution if exploited even before any application code runs."&gt;&lt;/figure&gt;&#10;&lt;p&gt;&amp;ldquo;I don&amp;rsquo;t use &lt;a href="https://www.php.net/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PHP&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; on &lt;a href="https://blog.schallbert.de/en/projects/migrating-jekyll-to-hugo/"&gt;my website&lt;/a&gt;,&amp;rdquo; I think to myself upon receiving the following message this morning:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Hi, quick heads-up: You might want to [&amp;hellip;] disconnect anything that uses PHP for now; [&amp;hellip;] a patch is coming later today.&amp;rdquo;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Or am I affected after all&amp;hellip;? I do use a managed Nextcloud instance, and &lt;a href="https://docs.nextcloud.com/server/stable/developer_manual/getting_started/glossary.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;isn&amp;rsquo;t Nextcloud written in &lt;em&gt;PHP&lt;/em&gt;, among other things&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;hellip;? As I write these lines, &lt;a href="https://github.com/nextcloud/all-in-one/releases/tag/v14.4.1" target="_blank" rel="noopener noreferrer" class="external-link"&gt;a fix for Nextcloud is becoming available&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Everything has to happen very quickly. There will certainly be a lot of overtime worked today.&lt;/p&gt;&#10;&lt;h3 id="status-october-21-2026-1030-am-cest"&gt;Status: October 21, 2026, 10:30 AM CEST&lt;/h3&gt;&#10;&lt;p&gt;Let&amp;rsquo;s rewind ten hours. There is no fix yet. Information about the CVEs has leaked and is already a few hours old. According to &lt;a href="https://en.wikipedia.org/wiki/PHP2Shell" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Wikipedia, &lt;code&gt;69%&lt;/code&gt; of all websites use PHP&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Exploiting such a highly critical vulnerability (with a &lt;a href="https://blog.schallbert.de/en/gitea-critical-vulnerability-active-exploitation-rce/#cvss-scoring"&gt;score&lt;/a&gt; of &lt;code&gt;9.2&lt;/code&gt;) in such a widely used technology naturally has a huge blast radius. Consequently, &amp;ldquo;the internet&amp;rdquo; is quite nervous.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;[&amp;hellip;] organisations should consider isolating internet-facing applications from public networks until a fix is ​​applied and verified.&amp;rdquo; — &lt;a href="https://cert.europa.eu/publications/security-advisories/2026-018/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;CERT-EU&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, accessed October 21, 2026, 10:40 AM&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Interesting. The recommendation is to &amp;ldquo;take it offline for now.&amp;rdquo;&lt;/p&gt;&#10;&lt;h2 id="step-2-immediate-measures-taking-nextcloud-offline"&gt;Step 2: Immediate Measures: Taking Nextcloud Offline&lt;/h2&gt;&#10;&lt;p&gt;To get ahead of a potential attack, I attempt to take my instance offline. I log in to my hoster&amp;rsquo;s platform, navigate to the &amp;ldquo;Settings&amp;rdquo; tab, and click on the &lt;code&gt;ownCloud Console&lt;/code&gt; section. This is Nextcloud&amp;rsquo;s command-line interface.&lt;/p&gt;&#10;&lt;p&gt;Once there, I discover that there is no command available to shut down or &amp;ldquo;de-publish&amp;rdquo; the instance. So, I opt for the next best thing:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;occ maintenance:mode --on&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So, I put my instance into &lt;a href="https://docs.nextcloud.com/server/stable/admin_manual/maintenance/backup.html#maintenance-mode" target="_blank" rel="noopener noreferrer" class="external-link"&gt;maintenance mode&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. This prevents new logins and freezes the database. Afterward, I read up a bit on the CVEs to see if this step is sufficient.&lt;/p&gt;&#10;&lt;p&gt;Apparently, it is not.&lt;/p&gt;&#10;&lt;p&gt;Through various forums and &lt;a href="https://thehackernews.com/2026/10/php2shell-two-unpatched-critical-php.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;HackerNews&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, I learn that an unauthenticated request can trigger the attack before any PHP code is even executed on the server side.&lt;/p&gt;&#10;&lt;p&gt;In other words, it doesn&amp;rsquo;t matter what state I set my instance to, as long as it is connected to the network, it remains vulnerable.&lt;/p&gt;&#10;&lt;h2 id="step-3-interim-containment-having-the-nextcloud-instance-taken-offline"&gt;Step 3: Interim Containment: ​​Having the Nextcloud Instance taken offline&lt;/h2&gt;&#10;&lt;p&gt;Even after several online searches, I cannot find a way to shut down my managed instance myself. I briefly consider killing the name resolution and DNS routing, but I quickly realize that by the time those changes propagate, the fix will likely already be available.&lt;/p&gt;&#10;&lt;h3 id="status-october-21-2026-1143-am-cest"&gt;Status: October 21, 2026, 11:43 AM CEST&lt;/h3&gt;&#10;&lt;p&gt;So, I submit a support ticket to my hoster, requesting assistance in taking my instance offline.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-21-hoster-response-takedown.avif" alt="Image: Screenshot of an E-mail from my hoster, stating that my Nextcloud instance has been deactivated until a patch is available and in place."&gt;&lt;/figure&gt;&#10;&lt;h3 id="status-october-21-2026-1201-pm-cest"&gt;Status: October 21, 2026, 12:01 PM CEST&lt;/h3&gt;&#10;&lt;p&gt;I receive an email from my hoster!&lt;/p&gt;&#10;&lt;p&gt;Sure enough, I can no longer access the instance. I&amp;rsquo;m met with an &lt;code&gt;ERR_CERT_COMMON_NAME_INVALID&lt;/code&gt; error. Dismissing that, a message appears: &lt;strong&gt;REQUESTED PAGE LOCKED&lt;/strong&gt;. Well done. The provider&amp;rsquo;s response time, including notifying the customer, was just 18 minutes.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-21-instance-locked.avif" alt="Image: A flatscreen showing a &amp;#39;no entry&amp;#39; symbol, connected to multiple small boxes that look like mini servers. Image caption says &amp;#39;Requested page locked&amp;#39;."&gt;&lt;/figure&gt;&#10;&lt;p&gt;Now, I shift from participant to observer. I&amp;rsquo;m seeing initial exploit attempts (or at least claims of them) circulating online, which brings to mind a slide from a presentation I recently watched: &lt;a href="https://kernel-recipes.org/en/2026/security-in-the-llm-age/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Security in the LLM age&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (at the &lt;code&gt;11:24&lt;/code&gt; mark).&lt;/p&gt;&#10;&lt;p&gt;In it, a Linux kernel expert points out that, as of 2026, exploits are appearing in the wild an average of seven days &lt;em&gt;before&lt;/em&gt; a patch for the affected software is even released.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;discover --&amp;gt; disclose --&amp;gt; patch --&amp;gt; deploy&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This traditional sequence of events only works well against &amp;ldquo;slow&amp;rdquo; attackers. We need something that allows us to get back ahead of the situation.&lt;/p&gt;&#10;&lt;h2 id="step-4-monitoring-the-situation---patch-availability-and-initial-deployment"&gt;Step 4: Monitoring the Situation - Patch Availability and Initial Deployment&lt;/h2&gt;&#10;&lt;p&gt;According to &lt;a href="https://thehackernews.com/2026/10/php-issues-emergency-patches-for-two.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;The Hacker News&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, the patch is set to become available around &lt;strong&gt;4:00 PM CEST&lt;/strong&gt;. I am obtaining the RSS feed from my hosting provider and awaiting further updates.&lt;/p&gt;&#10;&lt;h3 id="status-october-21-2026-1420-cest"&gt;Status: October 21, 2026, 14:20 CEST&lt;/h3&gt;&#10;&lt;p&gt;The Federal Office for Information Security (BSI) has also issued an &lt;a href="https://www.bsi.bund.de/SharedDocs/Cybersicherheitswarnungen/DE/2026/2026-297981-1032.pdf?__blob=publicationFile&amp;amp;v=4" target="_blank" rel="noopener noreferrer" class="external-link"&gt;IT security advisory&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, which classifies the vulnerability as having a &amp;ldquo;criticality level of 3/Orange&amp;rdquo; and notes the following:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Der Patch für die Schwachstelle ist heute 16 Uhr deutscher Zeit (14:00 UTC) angekündigt und sollte unverzüglich zu diesem Zeitpunkt installiert werden.&amp;rdquo; - BSI, 21.10.2026&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-21-hoster-vulnerability-notice.avif" alt="Image: Screenshot of my hoster&amp;#39;s notification feed for the PHP vulnerabilities in question. There are multiple entries at different points in time, all dated 21-Oct-2026."&gt;&lt;/figure&gt;&#10;&lt;h3 id="status-21102026-1634-cest"&gt;Status 21.10.2026 16:34 CEST&lt;/h3&gt;&#10;&lt;p&gt;My hoster &lt;a href="https://status.hetzner.com/de/incident/f4144d55-38c3-48d1-8664-7a3429355cd7" target="_blank" rel="noopener noreferrer" class="external-link"&gt;reports that patches are now available&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and that implementation has begun. Affected systems include not only the Nextcloud instances but also the hoster&amp;rsquo;s entire administration interface and their webmail service.&lt;/p&gt;&#10;&lt;h3 id="status-21102026-1952-cest"&gt;Status 21.10.2026 19:52 CEST&lt;/h3&gt;&#10;&lt;p&gt;My hoster reports that the patches have been successfully applied to their web hosting services and managed servers.&lt;/p&gt;&#10;&lt;h3 id="status-21102026-2240-cest"&gt;Status 21.10.2026 22:40 CEST&lt;/h3&gt;&#10;&lt;p&gt;Hetzner&amp;rsquo;s &lt;em&gt;KonsoleH&lt;/em&gt; application, which I use to manage my Nextcloud instance, is down.&lt;/p&gt;&#10;&lt;h3 id="status-21102026-2300-cest"&gt;Status 21.10.2026 23:00 CEST&lt;/h3&gt;&#10;&lt;p&gt;&lt;em&gt;KonsoleH&lt;/em&gt; is back online (and patched).&lt;/p&gt;&#10;&lt;h2 id="step-5-insights-and-conclusions"&gt;Step 5 Insights and Conclusions&lt;/h2&gt;&#10;&lt;p&gt;What have I learned from the events of this turbulent day? How should I handle security vulnerabilities in the future? And how can I even tell when a problem exists?&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;It is not just the service as a whole that is vulnerable, but its individual components as well. &amp;ndash;&amp;gt; Know all your components and keep an eye out for vulnerabilities and patches!&lt;/li&gt;&#10;&lt;li&gt;Adversaries have become incredibly fast (thanks to LLMs). &amp;ndash;&amp;gt; Expect exploits to appear even before patches are available!&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;I am currently compiling a list of reliable sources for information on vulnerabilities, news, and patches. Once the list is finalized, I will share it.&lt;/p&gt;&#10;&lt;h2 id="step-6-actions"&gt;Step 6 Actions&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Remove sensitive data from the network whenever possible. Line of thought: What if this were to disappear? Would availability within the LAN/intranet suffice?&lt;/li&gt;&#10;&lt;li&gt;Improved monitoring and abuse detection are the logical next steps: detect early, block early, report early, shut down early, and patch immediately.&lt;/li&gt;&#10;&lt;li&gt;Identify standard sources for information regarding the software in use. Perhaps build a CVE list scanner? Or a Hacker News filter?&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="addendum-oct-22-2026-0930-cest"&gt;Addendum: Oct 22, 2026, 09:30 CEST&lt;/h3&gt;&#10;&lt;p&gt;The hoster has patched my Nextcloud instance and brought it back online. All of my systems are back to normal. There is now also a &lt;a href="https://de.wikipedia.org/wiki/PHP2Shell" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Wikipedia entry&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; on the subject, which is kept very up to date.&lt;/p&gt;&#10;&lt;h2 id="step-7-initial-reflection"&gt;Step 7: Initial Reflection&lt;/h2&gt;&#10;&lt;p&gt;A few days have passed since the two security vulnerabilities were publicly disclosed and patches were made available. Let&amp;rsquo;s take a look at the current landscape across the web.&lt;/p&gt;&#10;&lt;h3 id="status-october-24-2026-1630-cest"&gt;Status: October 24, 2026, 16:30 CEST&lt;/h3&gt;&#10;&lt;p&gt;There are now many, many proofs of concept (PoCs) demonstrating how to exploit these PHP vulnerabilities. &lt;a href="https://github.com/search?q=php2shell&amp;amp;type=repositories" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Most notably on the platform GitHub&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Personally, I am not an expert in this field, so I have to rely on the assessments of others regarding the quality of these PoCs, the actual feasibility of exploiting them &amp;ldquo;in the wild,&amp;rdquo; and the reasons why this particular vulnerability accumulated such significant media attention.&lt;/p&gt;&#10;&lt;p&gt;In general, the rule is, of course: &lt;strong&gt;&amp;ldquo;Patch first, ask questions later.&amp;rdquo;&lt;/strong&gt; That is exactly what I did. To quote Felix von Leitner once again:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;One of the lessons learned from 20 years of working in the security field is that when one person claims a bug exists, and another claims it isn&amp;rsquo;t exploitable, it almost always turns out, in hindsight, that it was exploitable after all.&amp;rdquo; - Feliks von Leitner, alias &lt;a href="https://codeberg.org/tomas-jakobs/fefe-blog-backup/src/branch/main/content/2014/04" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Fefe, April 12, 2014&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, accessed October 24, 2026&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Regarding exploitability, &lt;a href="https://flare.io/learn/resources/blog/php2shell-exploits-underground-analysis" target="_blank" rel="noopener noreferrer" class="external-link"&gt;flare.io&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; which describes itself as a threat intelligence site, notes that most proof-of-concept (PoC) exploits for PHP2shell only functioned within laboratory environments. They are rarely applicable to real-world targets. Either because they require internal operational knowledge of the systems being attacked, because they depend on other vulnerabilities (such as the disabling of memory randomization), or because the PoCs themselves are merely &amp;ldquo;AI-slop&amp;rdquo;: poorly validated, hastily written software that promises more than it delivers.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://eliastaylor.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;eliastaylor&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; expresses a similar view:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;There isn&amp;rsquo;t an easy exploit for this PHP vulnerability.&amp;rdquo; - &lt;a href="https://eliastaylor.com/noexploit/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;https://eliastaylor.com/noexploit/&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, accessed October 24, 2026&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Writing less than ten hours after the attack vector and patch were released, he argues that:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;none of the proof-of-concept (PoC) exploits actually work. Not even on a standard installation and configuration of a pre-patch version of PHP&lt;/li&gt;&#10;&lt;li&gt;these two CVEs are far less critical than other historical security vulnerabilities (citing &lt;a href="https://en.wikipedia.org/wiki/Heartbleed" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Heartbleed&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; in OpenSSL as an example)&lt;/li&gt;&#10;&lt;li&gt;the exploits were not crafted by actual humans, but rather generated with the help of LLMs.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The core of the argument is that people are delegating their cognitive work to language models, failing to verify whether the system is actually doing what it claims to do. Let alone reading, understanding, or improving the code the system generates. Malicious botnets are still crafted by human hands, a process that takes time, especially when the vulnerabilities are not obvious or trivial to exploit.&lt;/p&gt;&#10;&lt;p&gt;As a brief exercise to help form an own assessment of the situation, I recommend scanning the &amp;ldquo;issue&amp;rdquo; discussions found on the GitHub repositories for many of these &amp;ldquo;PHP2Shell&amp;rdquo; PoCs.&lt;/p&gt;&#10;&lt;p&gt;However, the significant media attention has some advantages: it seems the &lt;a href="https://phpvulnerabilities.app/affected-products/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;world is patching quickly&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. I&amp;rsquo;ve taken a look at the discourse across various German and international forums: everywhere, people are pushing their hosting providers to apply patches, discussing the potential impact, and tracking which vendors have already released updates.&lt;/p&gt;&#10;&lt;p&gt;That still leaves the question of who actually benefits from all this. I have a few theories. However, it is still too early to draw conclusions or form a judgment.&lt;/p&gt;&#10;&lt;h3 id="status-october-27-2026-1700-cest"&gt;Status: October 27, 2026, 17:00 CEST&lt;/h3&gt;&#10;&lt;p&gt;The vulnerability has been &lt;a href="https://www.cisa.gov/news-events/alerts/2026/10/26/cisa-adds-two-known-exploited-vulnerabilities-catalog" target="_blank" rel="noopener noreferrer" class="external-link"&gt;officially confirmed as being exploited&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="step-8-analyzing-observations-and-adjusting-information-channels"&gt;Step 8: Analyzing observations and adjusting information channels&lt;/h2&gt;&#10;&lt;p&gt;I am now focusing on reports regarding the exploitation of these vulnerabilities. The sheer volume of Proofs of Concept has become unmanageable for any single individual. Some private individuals and &lt;a href="https://blog.cloudflare.com/php-ctf/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;companies like Cloudflare&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; have begun organizing &lt;a href="https://en.wikipedia.org/wiki/Capture_the_flag_%28cybersecurity%29" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&amp;ldquo;Capture The Flag&amp;rdquo; (CTF)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; competitions: they place intentionally vulnerable PHP instances online and challenge participants to compromise them and provide proof.&lt;/p&gt;&#10;&lt;p&gt;This is a clever way to separate the sheep from the goats. It forces individuals to test their PoCs against sparring partners who do not disclose their underlying stack. As an added layer of difficulty, some organizers require that standard defense mechanisms, such as &lt;a href="https://en.wikipedia.org/wiki/Web_application_firewall" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Web Application Firewalls (WAFs)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, be bypassed as well. If a PoC succeeds under these conditions, it can be considered a valid exploit demonstrated in a realistic environment.&lt;/p&gt;&#10;&lt;h3 id="understanding-the-poc"&gt;Understanding the PoC&lt;/h3&gt;&#10;&lt;p&gt;Thanks to &lt;a href="https://eliastaylor.com/altmans-cave/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;eliastaylor&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, I have located the PoC that successfully solved the &amp;ldquo;StealMyBitcoin&amp;rdquo; &lt;a href="https://github.com/KillaBoi/StealMyBitcoin-PoC/blob/main/stealmybitcoin_poc.py" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PHP2Shell CTF&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Both the comments within the Python file regarding the exploit mechanism and the discussion on GitHub—covering the specific LLM variant, prompt, and configuration used, are quite interesting.&lt;/p&gt;&#10;&lt;p&gt;As I interpret the sources and commentary surrounding this PoC, it offers an elegant solution to the shortcomings of previous attempts:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Effectively forcing PHP into a memory-constrained state during a multipart upload: Instead of sending thousands of requests in rapid succession, hoping to hit the &amp;ldquo;right&amp;rdquo; worker thread, this attack employs &amp;ldquo;deeply nested field names&amp;rdquo;&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;. This keeps the payload small while causing server-side memory consumption to explode, all within a single request.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This targets the vulnerability &lt;code&gt;CVE-2026-109618&lt;/code&gt;, where an &amp;ldquo;out-of-memory&amp;rdquo; condition can interrupt PHP while internal request data is only partially initialized; the subsequent request cleanup process may then mistakenly treat this invalid data as valid. The purpose of the &lt;a href="https://github.com/KillaBoi/StealMyBitcoin-PoC/blob/f394a6065f01f0afd48b867404017de01cd9811b/stealmybitcoin_poc.py#L228" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;heap_consumer&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is to manipulate the residual data left behind following the memory overflow.&lt;/p&gt;&#10;&lt;ol start="2"&gt;&#10;&lt;li&gt;Bypassing ASLR (Address Space Layout Randomization): Rather than ignoring ASLR in a real-world scenario (or simply disabling it in a lab environment), the memory overflow, data manipulation, and the subsequent request to trigger the processing of that manipulated data must all occur within the same session and on the same, exhausted worker process&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;. Otherwise, the affected memory region becomes nearly impossible to locate and will eventually be reset by the system once it detects that the worker has stalled. Consequently, the exploit must ensure that the malicious code is already in place by the time PHP invokes the corresponding destructor, which ultimately triggers the RCE (Remote Code Execution).&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This relates to the second CVE in the set: &lt;code&gt;CVE-2026-109619&lt;/code&gt; states &amp;ldquo;request-owned internal state can survive after its backing memory has been freed. A later request handled by the same worker may use that stale state before normal initialization resets it.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;It would be great to hear from a PHP engine expert on this! :)&lt;/p&gt;&#10;&lt;p&gt;This effectively places an tool in the hands of malicious actors, making (automated) exploitation feasible against &amp;ldquo;easy targets&amp;rdquo; that lack proper hardening.&lt;/p&gt;&#10;&lt;h3 id="hardening"&gt;Hardening&lt;/h3&gt;&#10;&lt;p&gt;The only way to resolve this issue is by applying the patch. The following hardening measures can at least increase resistance against this specific attack vector:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Either avoid offering the &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; command which is used to carry out the attack. Or restrict requests so that they cannot occur in rapid succession, within the same session, or from the same machine.&lt;/li&gt;&#10;&lt;li&gt;Increase the number of workers to the maximum, even if this impacts the machine&amp;rsquo;s performance. This makes it more difficult for an attacker to re-capture and exploit a worker that has already been compromised.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="later-that-same-day"&gt;Later that same day&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://thehackernews.com/2026/10/cisa-confirms-active-exploitation-of.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;The Hacker News&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; confirms the exploitation and lists affected companies, major corporations, and suppliers. The bottom line: patch, patch, patch!&lt;/p&gt;&#10;&lt;h2 id="status-as-of-october-28-2026-2130-cest"&gt;Status as of October 28, 2026, 21:30 CEST&lt;/h2&gt;&#10;&lt;p&gt;Cloudflare has since released new firewall rules for &lt;a href="https://developers.cloudflare.com/waf/change-log/changelog/#2026-10-28" target="_blank" rel="noopener noreferrer" class="external-link"&gt;its WAF&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; designed to intercept attackers before they reach the target servers. &lt;a href="https://thehackernews.com/2026/10/php2shell-attack-roots-25-servers.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;The Hacker News&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; reports on specific incidents involving initial damage. &lt;a href="https://thehackernews.com/2026/10/attackers-exploit-php2shell-flaws-in.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Threat intelligence firms&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; have confirmed these findings.&lt;/p&gt;&#10;&lt;p&gt;To round out this update, here is a &lt;a href="https://yourmom2shell.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;useful collection page containing information about &amp;ldquo;x-2-shell&amp;rdquo; vulnerabilities&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/KillaBoi/StealMyBitcoin-PoC/blob/f394a6065f01f0afd48b867404017de01cd9811b/stealmybitcoin_poc.py#L218" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Source: Exploit code&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:2"&gt;&#10;&lt;p&gt;&lt;a href="https://infosecmonkey.com/php2shell-cve-2026-109618-109619-explained/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Source: infosecmonkey.com&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>Use Password manager with hardware security keys</title><link>https://blog.schallbert.de/en/protecting-keepassxc-with-hardware-security-keys/</link><pubDate>Sun, 18 Oct 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/protecting-keepassxc-with-hardware-security-keys/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-18-keepassxc-unlock-database-popup-cover.avif"&#10; class="post-cover"&#10; alt="Image: KeePassXC&amp;#39;s database unlock popup as presented on Linux and Windows machines. The option &amp;#39;use hardware key&amp;#39; is ticked."&#10; title="Use Password manager with hardware security keys" /&gt;&#10;&lt;h2 id="the-password-manager"&gt;The Password Manager&lt;/h2&gt;&#10;&lt;p&gt;The web is full of various password management solutions. This is understandable, given that we have an ever-increasing number of accounts, and no one wants to memorize passwords in the hundreds. So, let&amp;rsquo;s get straight to the point:&lt;/p&gt;&#10;&lt;p&gt;From the vast array of options, I evaluated three password managers based on my criteria (secure, user-friendly, open-source, self-managed) to make a final choice:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://support.mozilla.org/en-US/kb/password-manager-remember-delete-edit-logins" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Firefox password manager&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (Online/Offline)&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://vaultwarden.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Vaultwarden&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (Online)&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://keepassxc.org/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;KeePassXC&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;/&lt;a href="https://www.keepassdx.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;KeePassDX&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (Offline)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="not-the-firefox-password-manager"&gt;Not the Firefox Password Manager&lt;/h3&gt;&#10;&lt;p&gt;I ruled out Firefox because I want to use my &lt;a href="https://blog.schallbert.de/en/hardware-security-keys-yubikey/"&gt;hardware security key&lt;/a&gt; to unlock it rather than memorizing a long master password. On the plus side, however, it offers great ease of use, automatic form filling, and synchronization capabilities, albeit via third-party infrastructure.&lt;/p&gt;&#10;&lt;h3 id="not-vaultwarden"&gt;Not Vaultwarden&lt;/h3&gt;&#10;&lt;p&gt;I had been focusing on Vaultwarden for a long time. Running it on a local network requires meeting numerous prerequisites and setting up supporting software. Specifically things like &lt;a href="https://helgeklein.com/blog/automatic-https-certificates-for-services-on-internal-home-network-without-opening-firewall-port/#dns-challenge--caddy-to-the-rescue" target="_blank" rel="noopener noreferrer" class="external-link"&gt;HTTPS on the LAN&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and &lt;a href="https://letsencrypt.org/docs/challenge-types/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ACME challenges&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Consequently, you need a dedicated small computer on your LAN, and ideally, a virtual private server to handle HTTPS traffic termination. Vaultwarden even supports incredibly convenient passkey-based login and can synchronize the password database across all devices on the network.&lt;/p&gt;&#10;&lt;p&gt;Ultimately, however, I avoided the complex setup, its additional dependencies, and software packages, all of which I would have had to maintain.&lt;/p&gt;&#10;&lt;h3 id="keepassxc"&gt;KeePassXC&lt;/h3&gt;&#10;&lt;p&gt;I experimented with various versions of KeePass and eventually settled on KeePassXC. The overall package simply feels more polished and modern; I can use the software across different operating systems, and the extensions needed for hardware security keys (HSKs) are included right out of the box.&lt;/p&gt;&#10;&lt;h2 id="setting-up-keepassxc-with-an-hsk"&gt;Setting up KeePassXC with an HSK&lt;/h2&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-18-keepassxc-setup-database-hsk.avif" alt="Image: KeePassXC create new database screen. There is a main password field, a security key field to protect with a local/remote key file, and a Challenge-response field to work with Hardware Security Keys."&gt;&lt;/figure&gt;&#10;KeePass uses the &lt;a href="https://en.wikipedia.org/wiki/HMAC" target="_blank" rel="noopener noreferrer" class="external-link"&gt;keyed-hash message authentication code&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (HMAC) method to unlock the password database using hardware security keys. It relies on a shared secret key that must be present on both sides but is not exchanged between the parties.&lt;/p&gt;&#10;&lt;p&gt;Typically, you generate the secret key on the hardware security key and then copy it into the KeePassXC input field. But let&amp;rsquo;s take it step by step, as the process depends slightly on the specific HSK being used.&lt;/p&gt;&#10;&lt;h3 id="yubikey"&gt;YubiKey&lt;/h3&gt;&#10;&lt;p&gt;Yubico has prepared a guide specifically for &lt;a href="https://support.yubico.com/s/article/Using-your-YubiKey-with-KeePass" target="_blank" rel="noopener noreferrer" class="external-link"&gt;use with KeePass&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. A YubiKey has two configurable &lt;a href="https://docs.yubico.com/yesdk/users-manual/application-otp/slots.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;slots&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; that support various cryptographic functions. From the function selection, choose &lt;code&gt;Challenge-response: program a challenge-response credential&lt;/code&gt;. The YubiKey then generates a &lt;code&gt;20-byte HEX code&lt;/code&gt;, which can subsequently be copied into KeePassXC.&lt;/p&gt;&#10;&lt;p&gt;It is important to note that the displayed code cannot be extracted later. Therefore, if the database is to have a secondary key (which is recommended), the code must be written down and then also stored in the secondary key.&lt;/p&gt;&#10;&lt;p&gt;For the YubiKey, I recommend leaving the &lt;code&gt;requires touch&lt;/code&gt; option unchecked. Otherwise, you have to touch the YubiKey every time you use a password. Even more annoying is that touching the key also becomes necessary when adding or modifying a password entry.&lt;/p&gt;&#10;&lt;h3 id="nitrokey"&gt;Nitrokey&lt;/h3&gt;&#10;&lt;p&gt;Nitrokey specifies that the secret be generated as a &lt;code&gt;20-byte base32 code&lt;/code&gt;. It can store exactly one HMAC secret.&#10;Nitrokey also provides an &lt;a href="https://docs.nitrokey.com/software/nk-app2/keepassxc" target="_blank" rel="noopener noreferrer" class="external-link"&gt;online guide for storing the HMAC secret&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for use with KeePassXC. As with the YubiKey, this guide relies on using the GUI app.&lt;/p&gt;&#10;&lt;h2 id="yubikey--compatibility"&gt;YubiKey Compatibility&lt;/h2&gt;&#10;&lt;p&gt;Ideally, I would store the key generated on the YubiKey onto the Nitrokey, or vice versa. That way, I would have a backup key and could access the KeePassXC database using either one.&lt;/p&gt;&#10;&lt;p&gt;But behold, the Nitrokey does not accept the YubiKey&amp;rsquo;s secret:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-18-nitrokey-challenge-response-does-not-accept-yubi-key.avif" alt="Image: The Nitrokey app, being confronted with a YubiKey HMAC secret, returns the error message &amp;#39;Secret is not in base32.&amp;#39; and cannot save the key"&gt;&lt;/figure&gt;&#10;&lt;p&gt;The same thing happens when I try to store a secret generated on the Nitrokey onto the YubiKey:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-18-yubikey-challenge-response-does-not-accept-nitro-key.avif" alt="Image: The Yubikey app, being confronted with a Nitrokey HMAC secret, returns the error message &amp;#39;Invalid format, allowed characters: 0123456789abcdef&amp;#39; and cannot save the key"&gt;&lt;/figure&gt;&#10;&lt;p&gt;I needed a workaround for this. So, taking a pragmatic approach, I created a second password database. Both were intended to hold the same content but use different keys to unlock. I later learned that this wasn&amp;rsquo;t actually necessary. Nevertheless, here are the provisional solutions I came up with initially.&lt;/p&gt;&#10;&lt;h3 id="dead-end-a-cloning-entries-from-the-database"&gt;Dead End A: Cloning entries from the database&lt;/h3&gt;&#10;&lt;p&gt;I populate the first database with passwords, sorting them into groups. Then, I clone a group, open the second database, and drag and drop the clone over.&lt;/p&gt;&#10;&lt;p&gt;Drawbacks: I have to repeat this process for every group, as drag-and-drop functionality isn&amp;rsquo;t supported for the &lt;code&gt;root&lt;/code&gt; group. Whenever I make a change in &lt;code&gt;Database_yubikey&lt;/code&gt;, I also have to update &lt;code&gt;Database_nitrokey&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;This becomes tedious, especially while the database is still under construction.&lt;/p&gt;&#10;&lt;h3 id="dead-end-b-copying-the-entire-database-and-updating-the-hmac"&gt;Dead End B: Copying the entire database and updating the HMAC&lt;/h3&gt;&#10;&lt;p&gt;To do this, you store the HMAC secrets for both hardware security keys (HSKs) within the password manager. When the database changes, you create a copy of the database file, open it, and copy the secret belonging to the &lt;em&gt;other&lt;/em&gt; HSK. Then, you navigate to &lt;code&gt;Database&lt;/code&gt; &amp;gt; &lt;code&gt;Database Security&lt;/code&gt; &amp;gt; &lt;code&gt;Change Challenge-Response&lt;/code&gt; and enter that secret. The database now works with the other key.&lt;/p&gt;&#10;&lt;p&gt;Drawback: Requires manual updating of both databases. The challenge-response setup has to be reconfigured every time.&lt;/p&gt;&#10;&lt;h3 id="solution-converting-the-hmac-secret"&gt;Solution: Converting the HMAC secret&lt;/h3&gt;&#10;&lt;p&gt;I wondered if I could use the command line instead of the app to store the HMAC secret for my Nitrokey. Command-line tools like &lt;code&gt;nitropy&lt;/code&gt; usually offer more options than their GUI counterparts. Perhaps this would allow me to solve the compatibility issue. Here is the &lt;a href="https://github.com/Nitrokey/nitrokey-documentation/blob/main/source/components/source/components/software/nitropy/all-platforms/installation.rst" target="_blank" rel="noopener noreferrer" class="external-link"&gt;link to the nitropy installation guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;While looking for suitable documentation, I came across &lt;a href="https://support.nitrokey.com/t/setup-otp-challenge-response-for-keepassxc/5497" target="_blank" rel="noopener noreferrer" class="external-link"&gt;this interesting post about challenge-response&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. It made me realize something that tech-savvy people, or at least those who are used to cryptography, or work close to hardware, have known for a long time:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Data with a size of &lt;code&gt;20 bytes&lt;/code&gt; can be represented as 40 characters in &lt;code&gt;hex&lt;/code&gt; AND as 32 characters in &lt;code&gt;base32&lt;/code&gt;.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Consequently, an HMAC secret can be converted and stored on both the YubiKey and the Nitrokey. To do this, I use the small utility &lt;a href="https://linux.die.net/man/1/xxd" target="_blank" rel="noopener noreferrer" class="external-link"&gt;xxd&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to generate a &lt;code&gt;base32&lt;/code&gt; phrase for my Nitrokey from the YubiKey&amp;rsquo;s hex code.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# open a new terminal&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# make sure the secrets are not saved to history (command for zsh)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;history -p &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# c0ffee = secret hex key&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# xxd convert input to binary (-r), output as plain hexdump (-p)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# interpret as base32 string&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo -n &lt;span style="color:#e6db74"&gt;&amp;#39;c0ffee&amp;#39;&lt;/span&gt; | xxd -r -p | base32&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;YD764&lt;span style="color:#f92672"&gt;===&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# close terminal&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Alternatively, it also works the other way round:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# open a new terminal&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# make sure the secrets are not saved to history (command for zsh)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;history -p &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# YD764=== = secret base32 key&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo -n &lt;span style="color:#e6db74"&gt;&amp;#39;YD764===&amp;#39;&lt;/span&gt; | base32 -d | xxd -p&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;c0ffee&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# close terminal&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="keepassxc-keeping-passwords-neatly-organized"&gt;KeePassXC: Keeping Passwords Neatly Organized&lt;/h2&gt;&#10;&lt;p&gt;If you avoid using &amp;ldquo;Log in with Google&amp;rdquo; options for third-party services, you can end up accumulating a large number of passwords over time. To make finding and using these passwords easier on both mobile and desktop, it makes sense to organize them into logical groups.&lt;/p&gt;&#10;&lt;h3 id="password-families"&gt;Password Families&lt;/h3&gt;&#10;&lt;p&gt;After some experimentation, the following naming scheme has proven effective for titles when I need to store multiple related passwords:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-py" data-lang="py"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;baseapp&lt;span style="color:#f92672"&gt;.&lt;/span&gt;attribute&lt;span style="color:#f92672"&gt;.&lt;/span&gt;specialization&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For example, here is how the login details for my &lt;a href="https://blog.schallbert.de/en/gitea-critical-vulnerability-active-exploitation-rce/"&gt;hardened Gitea setup&lt;/a&gt; would look:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-py" data-lang="py"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea&lt;span style="color:#f92672"&gt;.&lt;/span&gt;login&lt;span style="color:#f92672"&gt;.&lt;/span&gt;schallbert&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea&lt;span style="color:#f92672"&gt;.&lt;/span&gt;login&lt;span style="color:#f92672"&gt;.&lt;/span&gt;testuser&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea&lt;span style="color:#f92672"&gt;.&lt;/span&gt;runner&lt;span style="color:#f92672"&gt;.&lt;/span&gt;registration&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea&lt;span style="color:#f92672"&gt;.&lt;/span&gt;recoverykey&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="password-groups"&gt;Password groups&lt;/h3&gt;&#10;&lt;p&gt;I consider the following groups of passwords to be sensible:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Group&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Description&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Devices&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Computer, phone, router, keys&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Finance&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Banks, credit cards, insurances, payment&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Identity&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Email, identity providers, single sign-in&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Shopping&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Online stores, streaming, subscriptions&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Games&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Platforms, launchers, servers&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Social&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Forums, Messengers, Social networks&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Backup&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Recovery keys, licenses, passphrases&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;In doing so, I make sure not to create too many subgroups. Otherwise, it is easy to get confused, especially in the mobile view.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-18-keepassxc-password-groups.avif" alt="Image: KeePassXC app with unlocked database. We see different groups of passwords and single entries, each arranged nicely with a logo/symbol image."&gt;&lt;/figure&gt;&#10;&lt;h3 id="icons-"&gt;Icons 🔑&lt;/h3&gt;&#10;&lt;p&gt;KeePassXC offers the wonderful feature of downloading the target website&amp;rsquo;s favicon and using it as an icon. This makes navigating the password manager even easier.&lt;/p&gt;&#10;&lt;p&gt;However, the favicons are all bitmaps. If you prefer to use high-quality vector graphics, you can specify the path to an icon pack under &amp;ldquo;Choose Icon.&amp;rdquo; I downloaded the &lt;a href="https://aegis-icons.github.io/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Aegis icons&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for this purpose and am very happy with them.&lt;/p&gt;&#10;</description></item><item><title>Critical vulnerabilities in Gitea (and Forgejo)</title><link>https://blog.schallbert.de/en/gitea-critical-vulnerability-active-exploitation-rce/</link><pubDate>Sun, 04 Oct 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/gitea-critical-vulnerability-active-exploitation-rce/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-04-gitea-lower-than-V27-vulnerable-cve.avif"&#10; class="post-cover"&#10; alt="Image: The logo of Gitea, a cup of tea. It is boiling hot, and steam is emanating from the cup&amp;#39;s contents. In one of the steam clouds, there is a Jolly Roger. The image caption says &amp;#39;Gitea "&#10; title="Critical vulnerabilities in Gitea (and Forgejo)" /&gt;&#10;&lt;aside class="update-box update-box--error" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ⛔&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Update: Also affects Forgejo v16.0.4 and below&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-10-02T00:00:00Z"&gt;&#10; 2026-10-02&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; Based on the &lt;a href="https://forgejo.org/releases/16.x/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;release notes&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, I consider the software forge solution &lt;a href="https://forgejo.org/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Forgejo&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (a fork of Gitea) to be affected by the CVEs described below as well. All CVEs discussed in this article are resolved only in version v16.0.5 (released September 17, 2026) and later versions. Therefore, whenever the word &amp;lsquo;Gitea&amp;rsquo; appears in the article, Forgejo is explicitly included.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h2 id="becoming-aware"&gt;Becoming Aware&lt;/h2&gt;&#10;&lt;p&gt;As is so often the case, this story begins with a network of human communication. This &lt;a href="https://blog.schallbert.de/en/gitea-critical-vulnerability-active-exploitation-rce/#credits"&gt;invaluable exchange&lt;/a&gt; has already contributed significantly to the security of the software powering this blog. Now, I am able to close another security vulnerability. But first things first.&lt;/p&gt;&#10;&lt;h3 id="gitea-as-a-target"&gt;Gitea as a Target&lt;/h3&gt;&#10;&lt;p&gt;I heard that the self-hosted version control solution &lt;a href="https://en.wikipedia.org/wiki/Gitea" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; used by an acquaintance of mine had become the target of an attack. This attack enabled &lt;a href="https://www.crowdstrike.com/en-us/cybersecurity-101/cyberattacks/remote-code-execution/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;RCE&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (Remote Code Execution), which was subsequently exploited.&lt;/p&gt;&#10;&lt;h3 id="why-is-gitea-an-attractive-target"&gt;Why is Gitea an attractive target?&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Theft of (corporate) secrets: Access to repositories puts confidential source code at risk of exposure.&lt;/li&gt;&#10;&lt;li&gt;Exfiltration of access credentials: If a product with interfaces linked to Gitea is managed there, &lt;a href="https://en.wikipedia.org/wiki/API_key" target="_blank" rel="noopener noreferrer" class="external-link"&gt;API keys&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, accounts, and passwords are at risk if stored unencrypted. Tools like &lt;a href="https://dev.to/vnjogani/a-guide-to-git-secret-49g3" target="_blank" rel="noopener noreferrer" class="external-link"&gt;git-secret&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; offer a remedy, but they are not yet widely adopted due to the added complexity they introduce.&lt;/li&gt;&#10;&lt;li&gt;Classic privilege escalation: Under certain circumstances, breaking out of the Gitea software can allow an attacker to take over the entire machine.&lt;/li&gt;&#10;&lt;li&gt;Exploiting the &lt;a href="https://gitea.com/gitea/runner" target="_blank" rel="noopener noreferrer" class="external-link"&gt;runner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;: If an attacker manages to hijack the runner, they can use the host machine&amp;rsquo;s resources. Even without elevated privileges. And misuse them for their own purposes (e.g., &lt;a href="https://dev.to/golu12/crypto-mining-is-killing-all-free-cicd-platforms-28ip" target="_blank" rel="noopener noreferrer" class="external-link"&gt;cryptominers in CI/CD&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;).&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="what-is-rce"&gt;What is RCE?&lt;/h3&gt;&#10;&lt;p&gt;Remote Code Execution (RCE) refers to a successful attack in which unauthorized code is injected and executed remotely over a network, without any action required by the owner or administrator of the affected resource.&lt;/p&gt;&#10;&lt;h2 id="the-gitea-ecosystem"&gt;The Gitea Ecosystem&lt;/h2&gt;&#10;&lt;p&gt;As a version control system and &lt;a href="https://en.wikipedia.org/wiki/Forge_%28software%29" target="_blank" rel="noopener noreferrer" class="external-link"&gt;forge&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, Gitea&amp;rsquo;s core functionality includes the ability to develop and build software collaboratively via a web interface. Consequently, the system is designed to allow users to register and, in some cases, execute &lt;a href="https://github.com/features/actions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Actions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to automate software builds and/or deployments.&lt;/p&gt;&#10;&lt;p&gt;This very mechanism of automatically fetching and executing code can have serious consequences if security vulnerabilities exist within the management software itself.&lt;/p&gt;&#10;&lt;h3 id="vulnerability-via-open-registration"&gt;Vulnerability via Open Registration&lt;/h3&gt;&#10;&lt;p&gt;If an operator leaves the new account registration setting (&amp;ldquo;self-registration&amp;rdquo;) at its default value (&lt;code&gt;DISABLE_REGISTRATION = false&lt;/code&gt;) in the Gitea configuration, users can sign up, create their own repositories, open issues, and perform any action that does not require administrative privileges.&lt;/p&gt;&#10;&lt;p&gt;If the default setting &lt;code&gt;REQUIRE_SIGNIN_VIEW = false&lt;/code&gt; is also used, unauthenticated visitors can view public repositories, browse the Gitea site, and, most importantly, interact with the public &lt;a href="https://docs.gitea.com/api/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea API&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="vulnerability-due-to-default-configuration-for-reverse-proxies"&gt;Vulnerability due to default configuration for reverse proxies&lt;/h3&gt;&#10;&lt;p&gt;In the default configuration of the Gitea Docker image, Gitea versions prior to v1.26.3 trust all proxies.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/app.ini&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[security]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REVERSE_PROXY_LIMIT&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REVERSE_PROXY_TRUSTED_PROXIES&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;* # This is a vulnerability!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="the-security-vulnerabilities"&gt;The Security Vulnerabilities&lt;/h2&gt;&#10;&lt;p&gt;What are we dealing with here? Several critical security vulnerabilities with a &lt;a href="https://www.first.org/cvss/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;CVSS score&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; of &lt;code&gt;9.8/10&lt;/code&gt;, for which known instances of exploitation exist.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-04-cvss-calculator.avif" alt="Image: Common vulnerability scoring system calculator. The selectors are switched in a way that reproduces a critical vulnerability over a network vector without user interaction as discussed in this post."&gt;&lt;/figure&gt;&#10;&lt;h3 id="cvss-scoring"&gt;Side Note: The CVSS Rating&lt;/h3&gt;&#10;&lt;p&gt;To put the &amp;ldquo;critical&amp;rdquo; label into perspective: CVSS criteria are structured similarly to those used in classical hazard and &lt;a href="https://de.wikipedia.org/wiki/Risikoanalyse" target="_blank" rel="noopener noreferrer" class="external-link"&gt;risk analysis&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, a branch of engineering. Unlike in that field, however, the focus is not on analyzing potential failure modes, severity, probabilities of occurrence or failure, or detectability; instead, it centers on the difficulty of exploitation and the potential impact.&lt;/p&gt;&#10;&lt;h3 id="significance-of-individual-cvss-metrics"&gt;Significance of Individual CVSS Metrics&lt;/h3&gt;&#10;&lt;p&gt;The higher the CVSS score, the easier it is to exploit the vulnerability and the more severe the potential consequences regarding data security criteria (&lt;a href="https://en.wikipedia.org/wiki/CIA_triad" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Confidentiality, Integrity, Availability&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;). The image above illustrates a scenario involving a potential remote (network-based) attack that requires minimal effort (low complexity regarding system hardening) and no specific preconditions (such as targeting a specific vulnerable code path, requiring a specific system state, triggering a &lt;a href="https://en.wikipedia.org/wiki/Race_condition" target="_blank" rel="noopener noreferrer" class="external-link"&gt;race condition&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, etc.). Furthermore, the attack requires no elevated privileges (it can be performed as an unauthenticated/unauthorized guest) and involves no interaction with the system&amp;rsquo;s user or administrator.&lt;/p&gt;&#10;&lt;p&gt;In isolation, this might not be critical if the system is not required for operations (&lt;code&gt;Score 0.0&lt;/code&gt;) and holds no valuable data. However, if sensitive data is present (&lt;code&gt;Confidentiality, 8.7&lt;/code&gt;), or if there is a database whose values can be manipulated (&lt;code&gt;Integrity, 8.7&lt;/code&gt;), or even if the availability of essential data is compromised (&lt;code&gt;Availability, 8.7&lt;/code&gt;), the risk level shifts to &amp;ldquo;High.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;With this context in mind, we can now apply these concepts to the critical vulnerabilities found in Gitea.&lt;/p&gt;&#10;&lt;h3 id="cve-2026-20896-authentication-bug-in-gitea-through-reverse-proxies"&gt;CVE-2026-20896: Authentication bug in Gitea through reverse proxies&lt;/h3&gt;&#10;&lt;p&gt;If an attacker gains access behind the reverse proxy (or if no reverse proxy is deployed in front of the system), they can send a request containing the &lt;code&gt;X-WEBAUTH-USER&lt;/code&gt; header directly to the Gitea instance, effectively impersonating a reverse proxy. If Gitea is configured to &lt;a href="https://app.opencve.io/cve/CVE-2026-20896" target="_blank" rel="noopener noreferrer" class="external-link"&gt;trust all reverse proxies&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (&lt;code&gt;REVERSE_PROXY_TRUSTED_PROXIES = *&lt;/code&gt;), it assumes that the user has &lt;a href="https://df00tech.com/detections/blog/cve-2026-20896" target="_blank" rel="noopener noreferrer" class="external-link"&gt;already been authenticated and a valid session exists&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The attacker thus gains user rights with write access to repositories, even though they haven&amp;rsquo;t even created an account.&lt;/p&gt;&#10;&lt;p&gt;The actual vulnerability here is the unquestioning trust placed in the header&amp;rsquo;s sender without any further validation against a session token or similar mechanism; simply accepting the header from external sources is what creates the vulnerability.&lt;/p&gt;&#10;&lt;p&gt;There is &lt;a href="https://www.cloudlinktech.com/news/active-attacks-exploit-critical-gitea-docker-auth-bug/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;evidence of attacks that have already exploited&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; this bug.&lt;/p&gt;&#10;&lt;h3 id="cve-2026-59774-file-read-flaw-affecting-any-file-readable-by-the-gitea-service-account"&gt;CVE-2026-59774: File-read flaw affecting any file readable by the Gitea service account&lt;/h3&gt;&#10;&lt;p&gt;This critical vulnerability is caused by insufficient input validation in &lt;a href="https://docs.gitea.com/next/administration/external-renderers/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea&amp;rsquo;s built-in text renderer&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for &lt;a href="https://en.wikipedia.org/wiki/Org-mode" target="_blank" rel="noopener noreferrer" class="external-link"&gt;org-mode&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; files. A detailed description of the attack vector and potential exploitation methods can be found on &lt;a href="https://www.satyamrastogi.com/blog/gitea-org-mode-unauthenticated-file-read-cve-2026-59774/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Satyam Rastogi&amp;rsquo;s&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; excellent blog.&lt;/p&gt;&#10;&lt;p&gt;In short, an attacker with access to at least one public repository can craft a malicious &lt;code&gt;.org&lt;/code&gt; file, e.g. to read Gitea&amp;rsquo;s &lt;code&gt;app.ini&lt;/code&gt; configuration. This allows them to access secrets contained within the file, granting them elevated privileges or enabling lateral movement within the system.&lt;/p&gt;&#10;&lt;p&gt;According to &lt;a href="https://thehackernews.com/2026/08/critical-gitea-flaw-let-unauthenticated.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;The Hacker News&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and &lt;a href="https://github.com/go-gitea/gitea/security/advisories/GHSA-6v53-hr58-556r" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea&amp;rsquo;s own team&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, this flaw can also be exploited to extract runner registration tokens and hijack the runners themselves.&lt;/p&gt;&#10;&lt;h3 id="cve-2026-60004-rce-allowing-an-attacker-with-standard-user-privileges-to-execute-arbitrary-shell-commands-as-the-gitea-os-user"&gt;CVE-2026-60004: RCE allowing an attacker with standard user privileges to execute arbitrary shell commands as the Gitea OS user.&lt;/h3&gt;&#10;&lt;p&gt;This security vulnerability is particularly severe because it also enables &lt;a href="https://www.thehackerwire.com/vulnerability/CVE-2026-60004/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;privilege escalation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;The vulnerability in question is CVE-2026-60004 (CVSS score: 9.8), a case of remote code execution that allows an attacker with ordinary write access to a repository to execute arbitrary shell commands as the Gitea OS user.&amp;rdquo; - &lt;a href="https://thehackernews.com/2026/08/critical-gitea-rce-actively-exploited.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;thehackernews&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, Aug-2026&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Exploiting this vulnerability can therefore allow an attacker to take over the entire Gitea instance, provided they have write access to any repository (even a public one).&lt;/p&gt;&#10;&lt;h2 id="the-situation"&gt;The Situation&lt;/h2&gt;&#10;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-10-04-gitea-cve-chaining.avif" alt="Image: A decision tree, connecting the dots between the three discussed CVEs. Baseline message is that the system has to be regarded as compromised in case an attacker is able to get ordinary write access to any repository on the instance which can be done by exploiting any of the discussed CVEs."&gt;&lt;/figure&gt;&#10;&lt;p&gt;There are other critical security vulnerabilities; however, by combining the scenarios described above, attackers can (without significant effort)&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Operate as normal users on the Gitea system &lt;em&gt;without having registered&lt;/em&gt; and &lt;em&gt;without requiring SSH access&lt;/em&gt;&lt;/li&gt;&#10;&lt;li&gt;Impersonate the Gitea service user&lt;/li&gt;&#10;&lt;li&gt;Take over Actions or even inject and execute workflows&lt;/li&gt;&#10;&lt;li&gt;With slightly more effort, take over the entire instance, encrypt it, demand a ransom, etc.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;My acquaintance&amp;rsquo;s experience involved points one and three. The attack was most likely automated, carried out after the internet had been scanned for vulnerable instances.&lt;/p&gt;&#10;&lt;p&gt;In summary, the case at hand appears to be a &lt;a href="https://www.techtimes.com/articles/325724/20260827/cisa-confirms-gitea-cve-2026-60004-exploited-cryptominer-hits-5000-exposed-dev-servers.htm" target="_blank" rel="noopener noreferrer" class="external-link"&gt;mass-exploitation campaign&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; aimed at cryptomining.&lt;/p&gt;&#10;&lt;h3 id="the-cryptominer"&gt;The Cryptominer&lt;/h3&gt;&#10;&lt;p&gt;On my acquaintance&amp;rsquo;s system, the following file is stored as a statically linked executable (!): &lt;code&gt;gitea/data/gitea/sys_health_s3&lt;/code&gt;&#10;In his view, the file&amp;rsquo;s hash suggests that an &lt;a href="https://github.com/xmrig/xmrig" target="_blank" rel="noopener noreferrer" class="external-link"&gt;XMRig cryptominer&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; was deployed as a &lt;a href="https://www.cloudflare.com/learning/security/glossary/malicious-payload/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;malicious payload&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;; it communicates with &lt;code&gt;pool.hashvault.pro&lt;/code&gt; and consumes a large portion of the available CPU power.&lt;/p&gt;&#10;&lt;h3 id="potential-for-even-greater-damage"&gt;Potential for Even Greater Damage&lt;/h3&gt;&#10;&lt;p&gt;I was even able to find &lt;a href="https://thehackernews.com/2026/09/red-heron-exploits-gitea-rce-to.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;a recent (Sep 2026) example on Hacker News&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; regarding a worst-case scenario: source code theft, credential harvesting, and privilege escalation leading to &lt;code&gt;root&lt;/code&gt; access on the affected system.&lt;/p&gt;&#10;&lt;h2 id="how-do-i-detect-an-attack"&gt;How do I detect an attack?&lt;/h2&gt;&#10;&lt;p&gt;In the worst case, you don&amp;rsquo;t. If professionals are at work targeting the system, only the log files from the time immediately following the breach might offer clues. Provided, of course, that you read and interpret them right away. But I digress&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&#10;&lt;p&gt;Nevertheless, you should ask yourself the following questions:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Am I running a vulnerable version of Gitea (&lt;code&gt;&amp;lt;1.27.2&lt;/code&gt;)?&lt;/li&gt;&#10;&lt;li&gt;Have I avoided to use a monitoring solution that would have alerted me to anomalies?&lt;/li&gt;&#10;&lt;li&gt;Is there unusually &lt;em&gt;high system CPU load&lt;/em&gt;? This could reveal a cryptominer infection.&lt;/li&gt;&#10;&lt;li&gt;For this specific miner, you can also check if the following file exists on the system:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;find / -name &lt;span style="color:#e6db74"&gt;&amp;#39;.sys_health_s3&amp;#39;&lt;/span&gt; 2&amp;gt;/dev/null&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If the answer to even one of these questions is &amp;ldquo;yes,&amp;rdquo; immediate action is required. Given the severity of the vulnerabilities, the entire host system could be considered compromised, and should be if the Gitea container or any attached runner was configured as &amp;ldquo;rootful&amp;rdquo; (i.e., granted access to the Docker socket).&lt;/p&gt;&#10;&lt;h3 id="in-case-of-infection-quarantine"&gt;In Case of Infection: Quarantine&lt;/h3&gt;&#10;&lt;p&gt;Next steps: Immediately isolate the affected machine and take it offline. Shut down the containers, reboot the host machine, and apply updates. Afterward, update your Docker registry images and check log files (look at timestamps!) to determine if other parts of the system are affected. For further details, see &lt;a href="https://blog.schallbert.de/en/gitea-out-of-memory/"&gt;my article about a Gitea attack I experienced firsthand&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="solutions"&gt;Solutions&lt;/h2&gt;&#10;&lt;h3 id="update-gitea"&gt;Update Gitea!&lt;/h3&gt;&#10;&lt;p&gt;Gitea urgently needs to be updated to a version &lt;code&gt;&amp;gt;1.27.1&lt;/code&gt;. To do this, you can modify the &lt;code&gt;docker-compose.yml&lt;/code&gt; file and then restart the containers:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;gitea&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/gitea:latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;container_name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Further information on recent fixes can be found on &lt;a href="https://blog.gitea.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea&amp;rsquo;s blog&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The major release (version 27) addresses a long list of critical security vulnerabilities. I have singled out only a few of them in this article, as they affect my immediate environment and exploits for them are known.&lt;/p&gt;&#10;&lt;h3 id="running-gitea-and-the-runner-in-rootless-mode"&gt;Running Gitea and the runner in &amp;ldquo;rootless&amp;rdquo; mode&lt;/h3&gt;&#10;&lt;p&gt;If the exploitation of a security vulnerability leads to the compromise of Gitea or the Action Runner, the host system itself should not also be compromised. Running them in a container can limit the potential damage. I have therefore described how to set up a &amp;ldquo;rootless&amp;rdquo; solution &lt;a href="https://blog.schallbert.de/en/gitea-act-runner-dind/"&gt;here&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="additional-measures-hardening"&gt;Additional measures (hardening)&lt;/h2&gt;&#10;&lt;h3 id="cve-2026-20896-restricting-network-access"&gt;CVE-2026-20896: Restricting network access&lt;/h3&gt;&#10;&lt;p&gt;For the next steps, I am following the &lt;a href="https://df00tech.com/detections/CVE-2026-20896#kql" target="_blank" rel="noopener noreferrer" class="external-link"&gt;recommendation from d00ftech&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Include the address range of your reverse proxy. First, you need to determine the network address at which the reverse proxy is accessible. On my server, for example, both Gitea and the reverse proxy (Caddy) run within Docker. I therefore find the IP address as follows:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker inspect caddy &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;--format&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;{{range $name, $network := .NetworkSettings.Networks}}{{$name}}: {{$network.IPAddress}}{{&amp;#34;\n&amp;#34;}}{{end}}&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;caddy-proxy: 172.20.0.2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This value is now entered into &lt;code&gt;gitea/app.ini&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[security]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REVERSE_PROXY_LIMIT&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REVERSE_PROXY_TRUSTED_PROXIES&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;172.20.0.2/32&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ol start="2"&gt;&#10;&lt;li&gt;Block direct network access to Gitea that bypasses your own reverse proxy. To do this, the Gitea port (usually &lt;code&gt;3000&lt;/code&gt;) must not appear in the port mappings—for example, in the Docker configuration:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;services&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;gitea&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;ports&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#e6db74"&gt;&amp;#34;3000:3000&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# remove this!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="cve-2026-59774-update-gitea"&gt;CVE-2026-59774: Update Gitea!&lt;/h3&gt;&#10;&lt;p&gt;While it is possible to disable the org-mode renderer in &lt;code&gt;app.ini&lt;/code&gt; (see the &lt;a href="https://docs.gitea.com/next/administration/external-renderers/#appini-file-configuration" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; on this topic), in my view, the far better approach is to update Gitea itself!&lt;/p&gt;&#10;&lt;h3 id="cve-2026-60004-disable-self-registration-if-possible"&gt;CVE-2026-60004: Disable self-registration if possible&lt;/h3&gt;&#10;&lt;p&gt;The actual fix requires updating the Gitea version. For smaller instances with few users, it may also make sense to disable self-registration for new users. From now on, they will have to request registration from the administrators (unless they exploit one of the other CVEs described above).&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/app.ini&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[service]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DISABLE_REGISTRATION&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Other helpful settings include:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REGISTER_EMAIL_CONFIRM&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true # Requires user&amp;#39;s email to be confirmed for registration to complete&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLE_OPENID_SIGNUP&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false # Disallow logons via external API&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REQUIRE_SIGNIN_VIEW&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true # Force users to login before they can view content or use the public API&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="outlook-and-conclusion"&gt;Outlook and Conclusion&lt;/h2&gt;&#10;&lt;p&gt;Version control systems are highly attractive targets for a wide range of attackers: from cryptominers seeking to hijack resources and ransomware groups targeting source code and credentials, to highly professional, targeted attackers and state-sponsored actors, given that the software effectively serves as a form of infrastructure.&lt;/p&gt;&#10;&lt;p&gt;Therefore:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Apply all patches. Always. Immediately. Without exception.&amp;rdquo; - &lt;a href="https://codeberg.org/tomas-jakobs/fefe-blog-backup/src/branch/main/content/assets/hackback/hackback.htm#L509" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Felix von Leitner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, however, also says &lt;a href="https://www.bsi.bund.de/DE/Themen/Verbraucherinnen-und-Verbraucher/Informationen-und-Empfehlungen/Cyber-Sicherheitsempfehlungen/Updates-Browser-Open-Source-Software/Wichtige-Softwareupdates/wichtige-softwareupdates_node.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;BSI&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;What leaves me feeling a bit perplexed is the sheer number and severity of the security vulnerabilities that have been patched (35 CVEs in a single month, according to Gitea&amp;rsquo;s own blog!). From my naive security perspective, this solution is full of holes and I&amp;rsquo;m not sure if I should keep running Gitea.&lt;/p&gt;&#10;&lt;h3 id="immediate-harden-the-system"&gt;Immediate: Harden the system&lt;/h3&gt;&#10;&lt;p&gt;Obviously: Update Gitea to &lt;code&gt;latest&lt;/code&gt; and implement the hardening measures described above.&lt;/p&gt;&#10;&lt;h3 id="short-term-automated-updates"&gt;Short-term: Automated updates&lt;/h3&gt;&#10;&lt;p&gt;Over the coming days and weeks, I&amp;rsquo;ll be working on automatically scanning the Docker registry for software updates and deploying them to my server automatically as well. &lt;a href="https://blog.schallbert.de/en/auto-upgrade-docker-images/"&gt;Blog post available now&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="medium-term-time-for-monitoring"&gt;Medium-term: Time for monitoring&lt;/h3&gt;&#10;&lt;p&gt;I&amp;rsquo;ll be looking for a small, lightweight monitoring solution that flags anomalies—such as &amp;ldquo;CPU load unusually high since HH:MM:SS&amp;rdquo;—and can both execute and monitor &lt;a href="https://oneuptime.com/blog/post/2026-01-06-docker-health-checks/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;health checks&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Crucially, the tool itself shouldn&amp;rsquo;t become a potential entry point for attackers, meaning it needs to operate with virtually no privileges. I wonder if that&amp;rsquo;s easily achievable?&lt;/p&gt;&#10;&lt;h3 id="medium-term-2-get-rid-of-secrets"&gt;Medium-term 2: Get rid of secrets&lt;/h3&gt;&#10;&lt;p&gt;Unencrypted secrets have no place in repositories. Even a &lt;code&gt;.env&lt;/code&gt; file sitting on the host itself can no longer be considered secure. I&amp;rsquo;ll be giving this some thought and will report back once I&amp;rsquo;ve rolled out a solution.&lt;/p&gt;&#10;&lt;h3 id="medium-term-3-set-up-news-feeds"&gt;Medium-term 3: Set up news feeds&lt;/h3&gt;&#10;&lt;p&gt;This involves subscribing to newsletters or RSS feeds, ideally from all my software vendors, IT security institutions or journalists, and other sources. To stay informed about security vulnerabilities, patches, and system hardening measures.&lt;/p&gt;&#10;&lt;h3 id="credits"&gt;Long-term: Keeping the conversation going&lt;/h3&gt;&#10;&lt;p&gt;It ends just as it begins. Before I can solve problems with my software, before I even know those problems exist, right there! At the very beginning, before I&amp;rsquo;ve even decided which software to use, there are: other people.&lt;/p&gt;&#10;&lt;p&gt;People I can ask questions or turn to for help. People whose time I can claim for my own needs. Like messaging them out of the blue and pulling them away from what they were focused on.&lt;/p&gt;&#10;&lt;p&gt;People who know more than I do, who see things differently, or whose opinions I find interesting.&lt;/p&gt;&#10;&lt;p&gt;Colleagues, current and former. Acquaintances, friends, and family whom I meet, whether by chance or by appointment, to connect. Over coffee, ice cream, or a cold drink; to make music or spend an afternoon playing board games.&lt;/p&gt;&#10;&lt;p&gt;People who help me kindly and patiently, despite the interruptions and my silly questions. People who might roll their eyes for a moment but then explain things to me one more time anyway. People who somehow tolerate my monologues, critical follow-up questions, and occasional pushback.&lt;/p&gt;&#10;&lt;p&gt;People with whom I can engage in an exchange that, in the end, hopefully benefits both sides.&lt;/p&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--left"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/root/Schallbert.avif" alt="Image: A computer-drawn image of Schallbert, pixelized, and in 256 colors."&gt;&lt;/figure&gt;&#10;Thanks to all of you. Everything is much easier with people like you. Including running this blog, which takes a lot of my time. With you, I can discuss decisions, learn how you did things (and why you did them that way), and shift perspectives. And develop new ideas.&lt;/p&gt;&#10;&lt;p&gt;Please stay in my life.&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;A nod to &lt;a href="https://de.wikipedia.org/wiki/Fefes_Blog" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&amp;ldquo;Fefe&amp;rdquo; Felix von Leitner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Quote: &amp;ldquo;Assumptions [&amp;hellip;]: We are being attacked. We notice it promptly. Experience shows: No, we don&amp;rsquo;t. Typical timeframe before detecting stowaways: six months to five years.&amp;rdquo; - from the &lt;a href="https://codeberg.org/tomas-jakobs/fefe-blog-backup/src/commit/5d0e24d085463a5fc3b852edb589347f6d909a85/content/assets/hackback/hackback.htm#L634" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Hackback&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; presentation; link leads to &lt;a href="https://codeberg.org/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Codeberg&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>Hugo vs. Jekyll: Benchmark</title><link>https://blog.schallbert.de/en/hugo-versus-jekyll-benchmark/</link><pubDate>Wed, 30 Sep 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/hugo-versus-jekyll-benchmark/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-09-30-hugo-jekyll-benchmark.avif"&#10; class="post-cover"&#10; alt="Image: The result of a page load time and page bandwidth test in the browser. There is a cake diagram, showing the majority of data transfer accounting for images, followed by css with a fifth. HTML content is the smallest part with a tenth or less."&#10; title="Hugo vs. Jekyll: Benchmark" /&gt;&#10;&lt;h2 id="loading-times"&gt;Loading Times&lt;/h2&gt;&#10;&lt;p&gt;Let&amp;rsquo;s take a look at how my site loads using Jekyll compared to Hugo. To do this, I open the &lt;em&gt;Developer Tools (F12)&lt;/em&gt; in &lt;a href="https://www.firefox.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;my browser&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, select the &lt;em&gt;Network&lt;/em&gt; tab, and click on &lt;em&gt;All&lt;/em&gt;. Then, I reload the page.&lt;/p&gt;&#10;&lt;p&gt;This results in the following list:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/migrating-hugo-to-jekyll/blog_schallbert-transfersize-loadtime-hugo.avif" alt="Image: A snapshot of my browser window with the blog&amp;#39;s landing page opened. Developer options are activated, and the lower part of the image shows a table with the individual page content files, their size, and contribution to the load time. For hugo, it transfers a total of 189kB in 175ms."&gt;&lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;Image below: Load time of my landing page using Hugo&lt;/span&gt;&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;p&gt;It is worth noting that I am not actually comparing Jekyll with Hugo here, but rather their output. Consequently, the chosen themes are the primary factors determining size and time to load. In effect, I am comparing the &lt;em&gt;Minimal Mistakes theme in Jekyll&lt;/em&gt; with the &lt;em&gt;Terminal theme in Hugo&lt;/em&gt;. In this instance, Hugo is about &lt;code&gt;10%&lt;/code&gt; faster than Jekyll.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/migrating-hugo-to-jekyll/blog_schallbert-transfersize-loadtime-jekyll.avif" alt="Image: A snapshot of my browser window with the blog&amp;#39;s landing page opened. Developer options are activated, and the lower part of the image shows a table with the individual page content files, their size, and contribution to the load time. For jekyll, it transfers a total of 328kB in 306ms."&gt;&lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;Pictured below: Load time of my landing page using Jekyll&lt;/span&gt;&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;h2 id="size"&gt;Size&lt;/h2&gt;&#10;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/migrating-hugo-to-jekyll/blog_schallbert-landing-performance-analysis-terminaltheme.avif" alt="Image: A pie chart showing the output of the Firefox performance analysis tool within the developer tools. Analyzing the Hugo &amp;#39;Terminal&amp;#39; theme on one of my typical blog posts reveals that images account for three-quarters of the total transferred size. The final quarter consists largely of CSS, with HTML making up just 12 kB of the 165 kB total."&gt;&lt;/figure&gt;&#10;&lt;p&gt;I get a different chart when I click the stopwatch icon at the bottom left to run a &lt;a href="https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/performance_analysis/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;performance analysis&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. For my Jekyll implementation, the pie charts show that images make up the largest share, followed by CSS and JS. With Hugo, JS usage drops to near zero, and the share of CSS has also decreased.&lt;/p&gt;&#10;&lt;p&gt;The table below compares a few of my pages directly. However, this overview should be viewed with caution: the values can fluctuate upon reloading (even with the cache disabled). This is particularly true for embedded third-party content, such as my videos, which can yield widely varying figures. Overall, however, I have achieved significant optimizations using Hugo and the &amp;lsquo;Terminal&amp;rsquo; theme.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Item&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;minimal-mistakes&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;terminal-schallbert&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Jekyll&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Hugo&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;transferred&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;[kB]&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Diff [%]&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;time&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;[ms]&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Diff [%]&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Landing&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;163.47&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;98.68&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;-39&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;30&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;30&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Post&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2861&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1767&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;-38&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;340&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;270&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;-20&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;List page&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1507&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;140&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;-90&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;30&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;30&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;0&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;I manually implemented conditional loading for KaTeX in Hugo, ensuring it only loads on pages containing mathematical expressions (this saves &lt;code&gt;90kB&lt;/code&gt; in &lt;code&gt;min.css&lt;/code&gt; for users).&lt;/li&gt;&#10;&lt;li&gt;With Hugo, my CSS files are smaller. Only what I actually use gets loaded; there is hardly any unused code left.&lt;/li&gt;&#10;&lt;li&gt;Hugo automatically derives media preview files from high-resolution originals and uses an image processor to optimize them at build time for the display dimensions of the site. This optimization saves a significant amount of bandwidth when there are many small images.&lt;/li&gt;&#10;&lt;li&gt;Unlike the &lt;em&gt;Minimal Mistakes&lt;/em&gt; theme, the &lt;em&gt;Terminal&lt;/em&gt; theme does not display &amp;ldquo;related posts&amp;rdquo; cards. This eliminates the need to transfer four preview images.&lt;/li&gt;&#10;&lt;li&gt;I have almost no JavaScript content left in Hugo—one of the goals I had set for myself.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="deliverycontinuous-deployment"&gt;Delivery/Continuous Deployment&lt;/h2&gt;&#10;&lt;p&gt;Building and deploying with Jekyll in Gitea proved somewhat cumbersome: it wasn&amp;rsquo;t until I used a &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/"&gt;Jekyll Docker image&lt;/a&gt; that I was able to &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-native-jekyll/"&gt;set up Ruby correctly&lt;/a&gt;. Because of this, and the fact that I don&amp;rsquo;t allow my Actions to use caching, the build process takes quite a long time: first pulling the latest Docker container, updating Ruby and Jekyll, installing gems, checking out source files, and finally building and deploying. Even under optimal conditions, it takes more than &lt;code&gt;90 seconds&lt;/code&gt; for a new post to go live.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-09-30-CD-build-with-jekyll.avif" alt="Image: Gitea dashboard in the Actions tab. We see three successful workflow runs with a hugo full build &amp;amp; deploy of my site, taking 122sec, 138sec, and 160sec."&gt;&lt;/figure&gt;&#10;&lt;p&gt;For Hugo, the entire process is weigh faster thanks to quicker build times and pre-built containers (see my article on the &lt;a href="https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/"&gt;Continuous Deployment Pipeline with Hugo in Docker&lt;/a&gt;).&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-09-30-CD-build-with-hugo.avif" alt="Image: Gitea dashboard in the Actions tab. We see three successful workflow runs with a hugo full build &amp;amp; deploy of my site, taking 12sec, 9sec, and 21sec."&gt;&lt;/figure&gt;&#10;&lt;p&gt;Even in the worst-case scenario (see build below), for example, after restarting the runner following an update and a change to the Docker repository for my Hugo container, I remain well under &lt;code&gt;30 seconds&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="complexity"&gt;Complexity&lt;/h2&gt;&#10;&lt;p&gt;Clearly, Hugo is unbeatable here compared to Jekyll.&lt;/p&gt;&#10;&lt;p&gt;Since themes in Hugo are pluggable modules, they don&amp;rsquo;t require a dependency tree of Gems (as Jekyll does) to be fetched and installed. Consequently, there is much less that can go wrong. Locally, my &amp;ldquo;full build&amp;rdquo; time (bilingual) drops by &lt;strong&gt;75%&lt;/strong&gt;, and automatic builds triggered by changes to my Markdown files are both &lt;strong&gt;5x&lt;/strong&gt; faster and closer to the real thing: with Jekyll, enabling &amp;ldquo;incremental build&amp;rdquo; means paginator pages are no longer updated, meaning list views and overview pages aren&amp;rsquo;t built.&lt;/p&gt;&#10;&lt;p&gt;On the other hand, Jekyll allows for highly flexible, fine-grained manual intervention. I can design my own Gems and quite easily create special features for my site, such as a table of contents that scrolls along with the page. That is something I miss a bit in Hugo.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Conclusion: In terms of day-to-day usability, I definitely prefer Hugo over Jekyll. - &lt;em&gt;schallbert, Sep-2026&lt;/em&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;</description></item><item><title>Continuous Deployment for a Hugo site</title><link>https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/</link><pubDate>Tue, 15 Sep 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-09-15-dind-action-setup-cover.avif"&#10; class="post-cover"&#10; alt="Image: A hand-drawn Docker container layout image. It shows the runner container on a VM with a debian OS, which starts an action container, which in turn runs the hugo deployment container."&#10; title="Continuous Deployment for a Hugo site" /&gt;&#10;&lt;p&gt;I have just migrated my website &lt;a href="https://blog.schallbert.de/en/projects/migrating-jekyll-to-hugo/"&gt;from Hugo to Jekyll&lt;/a&gt;. Now, I want to automate the build and deployment process so that changes are rolled out as soon as I finish an article and push it via &lt;code&gt;git push&lt;/code&gt; followed by a merge into &lt;code&gt;main&lt;/code&gt; to my &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;Gitea instance&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="the-command-chain"&gt;The Command Chain&lt;/h2&gt;&#10;&lt;p&gt;I rent a &lt;a href="https://en.wikipedia.org/wiki/Virtual_private_server" target="_blank" rel="noopener noreferrer" class="external-link"&gt;VPS&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; where I run all my applications in Docker. The task now is to create a &lt;a href="https://docs.github.com/en/actions/concepts/workflows-and-actions/workflows" target="_blank" rel="noopener noreferrer" class="external-link"&gt;workflow&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; in Gitea (&amp;ldquo;Actions&amp;rdquo;) that checks out the repository update, passes it to &lt;a href="https://hub.docker.com/r/hugomods/hugo" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Hugo&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to generate the HTML pages, and then saves the output to the disk in a location where my web server can read and serve the files.&lt;/p&gt;&#10;&lt;h3 id="lessons-learned"&gt;Lessons Learned&lt;/h3&gt;&#10;&lt;p&gt;I had already managed to set this up for my &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/"&gt;Jekyll website build and deployment&lt;/a&gt;, so I can draw on some previous experience.&lt;/p&gt;&#10;&lt;p&gt;Typically, standardized actions are used for this purpose. I&amp;rsquo;ll simplify things significantly here and list only the key components, without deep-diving in messy details like permission settings (is Hugo allowed to write to this folder? Does the &lt;code&gt;hugo&lt;/code&gt; user have sufficient privileges?):&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/actions/checkout" target="_blank" rel="noopener noreferrer" class="external-link"&gt;checkout&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; makes a specific version of the source repository available&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/peaceiris/actions-hugo" target="_blank" rel="noopener noreferrer" class="external-link"&gt;actions-hugo&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; installs Hugo within the Action&amp;rsquo;s virtual machine so the website can be built&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/actions/upload-artifact" target="_blank" rel="noopener noreferrer" class="external-link"&gt;upload-artifact&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; uploads the site generated by Hugo to the web server&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;However, since I am operating within Docker on the same machine, I can skip the hassle of logging into the web server &amp;ldquo;from the outside&amp;rdquo;. So, I&amp;rsquo;m taking a different approach.&lt;/p&gt;&#10;&lt;h2 id="docker-shared-volumes"&gt;Docker shared volumes&lt;/h2&gt;&#10;&lt;p&gt;I can make the built files directly available to my &lt;em&gt;Caddy&lt;/em&gt; application by passing the volume (that &lt;em&gt;Caddy&lt;/em&gt; reads from) through to the Action.&lt;/p&gt;&#10;&lt;h3 id="heads-up-action-volume--hugo-volume"&gt;Heads up: Action volume != Hugo volume&lt;/h3&gt;&#10;&lt;p&gt;But this doesn&amp;rsquo;t work with &lt;em&gt;actions-hugo&lt;/em&gt;: It is not a Docker container itself, so it cannot mount volumes. While it does successfully build the website, it sends the artifacts into oblivion as soon as the Action finishes running.&lt;/p&gt;&#10;&lt;p&gt;So, I need a Hugo runtime that executes inside a Docker container itself, following the &lt;a href="https://blog.schallbert.de/en/gitea-act-runner-dind/"&gt;DinD approach&lt;/a&gt;. I can then pass the volume with write access.&lt;/p&gt;&#10;&lt;h2 id="the-workflow"&gt;The workflow&lt;/h2&gt;&#10;&lt;p&gt;But which Docker container should I choose?&lt;/p&gt;&#10;&lt;p&gt;Imagine here several hours of research and numerous failed attempts while setting up the workflow on my server. &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-09-15-hugo-unsuccessful-workflow-run-history.avif" alt="Image: Gitea workflow run overview. We see many actions at the bottom with a red &amp;#39;build failed&amp;#39; flag. Only the top two workflow runs were successful."&gt;&lt;/figure&gt;&lt;/p&gt;&#10;&lt;p&gt;My investigation revealed the following:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;checkout@v4&lt;/code&gt; requires &lt;a href="https://nodejs.org/en" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Node.js&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;—specifically, at least version &lt;em&gt;node:18&lt;/em&gt;. The Docker container must have it installed.&lt;/li&gt;&#10;&lt;li&gt;The action requires &lt;code&gt;git&lt;/code&gt;. Ideally, the Docker container should already include this as well.&lt;/li&gt;&#10;&lt;li&gt;Hugo needs &lt;code&gt;go&lt;/code&gt;. That makes sense.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="adjusting-the-runner-operating-system"&gt;Adjusting the runner operating system&lt;/h3&gt;&#10;&lt;p&gt;My runner from 2023 did not age well. It is based on &lt;code&gt;node:16-bullseye&lt;/code&gt;, a Debian-based VM. I experiment a bit with &lt;a href="https://hub.docker.com/_/node?tag=lts-alpine3.24" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Alpine&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, but that installation is too minimal for my needs and would require too many manual adjustments. After all, the runner has to execute other workflows besides this one. Ultimately, I decide to stick with Debian and just upgrade the Node version. The implementation in the runner configuration looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /gitea/runner/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# The labels of a runner are used to determine which jobs the runner can run, and how to run them. &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Like: [&amp;#34;macos-arm64:host&amp;#34;, &amp;#34;ubuntu-latest:docker://node:16-bullseye&amp;#34;, &amp;#34;ubuntu-22.04:docker://node:16-bullseye&amp;#34;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# If it&amp;#39;s empty when registering, it will ask for inputting labels. &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# If it&amp;#39;s empty when execute `deamon`, will use labels in `.runner` file. &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;labels&lt;/span&gt;: [&lt;span style="color:#ae81ff"&gt;ubuntu-latest:docker://node:20-bullseye]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Afterwards, the runner container must be restarted. Ideally, you should reboot Gitea as well; this ensures that runner registration works reliably.&lt;/p&gt;&#10;&lt;h3 id="writing-the-workflow-script"&gt;Writing the workflow script&lt;/h3&gt;&#10;&lt;p&gt;To handle the dependencies described above, I&amp;rsquo;m opting for a &amp;ldquo;batteries-included&amp;rdquo; approach. This allows me to assemble the components without manual adjustments and rely on the long-term maintenance of the containers, given their widespread use (with pull counts in the millions). &lt;a href="https://docker.hugomods.com/docs/tags/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;This website&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; was very helpful in making my selection.&lt;/p&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Update: Using a runner with Alpine 3.24 is actually possible.&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-09-22T00:00:00Z"&gt;&#10; 2026-09-22&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; If I execute the action in a container as described below, the runner itself no longer requires Node.js, since the checkout now takes place within the Hugo container. Consequently, the runner&amp;rsquo;s OS can be stripped down again, and &lt;code&gt;labels: [ubuntu-latest:docker://node:20-alpine3.24]&lt;/code&gt; can be used—provided no other action requires a direct checkout.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;p&gt;Finally, I create my workflow based on the container from &lt;a href="https://hub.docker.com/r/hugomods/hugo" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Hugomods&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /.gitea/workflows/build-deploy-with-hugo.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Deploy Hugo site&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;run-name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;${{ gitea.actor }} builds Hugo site&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;on&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;push&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;branches&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;main&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;jobs&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Build job&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;build&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;runs-on&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;ubuntu-latest&lt;/span&gt; &lt;span style="color:#75715e"&gt;# this is the &amp;#34;label&amp;#34; the runner will use and map to docker target OS&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;hugomods/hugo:latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/tmp/blog-artifacts:/tmp/blog-artifacts&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;steps&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;CHECKOUT ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/checkout@v4&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;with&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;submodules&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;recursive &lt;/span&gt; &lt;span style="color:#75715e"&gt;# Fetches Hugo themes&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;fetch-depth&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;BUILD WITH HUGO ---&lt;/span&gt; &lt;span style="color:#75715e"&gt;# chown -R hugo /tmp/blog-artifacts&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: |&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; hugo \&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; --minify \&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; --cleanDestinationDir \&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; --destination /tmp/blog-artifacts&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="cleaning-the-destination-folder"&gt;Cleaning the destination folder&lt;/h3&gt;&#10;&lt;p&gt;In the example above, I opted for a &amp;ldquo;direct deployment&amp;rdquo;. This is a basic approach without a safety net, and without copying artifacts around or restarting the server. The reason: the Hugo build process takes less than &lt;code&gt;2,000ms&lt;/code&gt; for my site. It is only within this window that Caddy could theoretically encounter and serve &amp;ldquo;corrupt&amp;rdquo; data. Specifically, the window is even smaller as occurrence is limited to the Hugo build writing artifacts to the disk.&lt;/p&gt;&#10;&lt;p&gt;To minimize inconsistencies regardless, I use the &lt;code&gt;--cleanDestinationDir&lt;/code&gt; option with Hugo. This ensures the destination directory is always clean; consequently, in the event of an issue, Caddy won&amp;rsquo;t display a broken site but will instead generate a &lt;code&gt;404 error&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="submodules"&gt;Submodules&lt;/h3&gt;&#10;&lt;p&gt;As noted in &lt;a href="https://blog.schallbert.de/en/projects/migrating-jekyll-to-hugo/#opportunities"&gt;my project documentation&lt;/a&gt;, you can use &amp;ldquo;submodules&amp;rdquo; in Hugo to load themes. These are integrated into the project and—provided they are linked correctly—can be utilized within the workflow:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;with&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;submodules&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;recursive &lt;/span&gt; &lt;span style="color:#75715e"&gt;# Fetches Hugo themes&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;fetch-depth&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If everything is configured correctly, you should not encounter the following error message:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# workflow action&amp;#39;s error message due to incoherent submodules&amp;#39; commit references&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# actions/checkout@v4:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Fetching submodules&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/usr/bin/git submodule sync&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/usr/bin/git -c protocol.version&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt; submodule update --init --force&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Submodule &lt;span style="color:#e6db74"&gt;&amp;#39;themes/terminal&amp;#39;&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;https://github.com/panr/hugo-theme-terminal.git&lt;span style="color:#f92672"&gt;)&lt;/span&gt; registered &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; path &lt;span style="color:#e6db74"&gt;&amp;#39;themes/terminal&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Cloning into &lt;span style="color:#e6db74"&gt;&amp;#39;/workspace/schallbert/blog-hugo/themes/terminal&amp;#39;&lt;/span&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;fatal: remote error: upload-pack: not our ref 719505fc89332baa69bffb90cee708ff124dd143&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Fetched in submodule path &lt;span style="color:#e6db74"&gt;&amp;#39;themes/terminal&amp;#39;&lt;/span&gt;, but it did not contain 719505fc89332baa69bffb90cee708ff124dd143. Direct fetching of that commit failed.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Error: The process &lt;span style="color:#e6db74"&gt;&amp;#39;/usr/bin/git&amp;#39;&lt;/span&gt; failed with exit code &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Error: Process completed with exit code 1.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A prerequisite is that the submodules have been correctly loaded locally:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~ git submodule add -f https://github.com/&amp;lt;my/hugo-theme&amp;gt;.git themes/my-hugo-theme&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~ git submodule update --init --recursive&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The status check must not return an empty string; instead, it must include a commit hash. Example:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~ git submodule status&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;44d9a1890d228745ffc300b37a7d73e940ef9fa9 themes/terminal &lt;span style="color:#f92672"&gt;(&lt;/span&gt;v4.2.5&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Further confirmation is provided by the &lt;code&gt;.gitmodules&lt;/code&gt; file, which must contain a reference to the theme:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:➜/blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;main&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ nano .gitmodules&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;submodule &lt;span style="color:#e6db74"&gt;&amp;#34;themes/terminal&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;path &lt;span style="color:#f92672"&gt;=&lt;/span&gt; themes/terminal&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; https://github.com/panr/hugo-theme-terminal.git&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If this does not work, the submodules must be completely removed and then re-initialized as described above:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;git submodule deinit -f themes/&amp;lt;my-theme&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;git rm -r --cached themes/&amp;lt;my-theme&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;rm -rf .git/modules/themes/&amp;lt;my-theme&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="common-issues-hugo-versions-and-deprecations"&gt;Common Issues: Hugo Versions and Deprecations&lt;/h2&gt;&#10;&lt;p&gt;The &lt;code&gt;checkout&lt;/code&gt; action completed successfully, but the Hugo build fails:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;error&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;calling&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;partial&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;/workspace/schallbert/blog-hugo/layouts/_partials/head.html:37:40&amp;#34;&lt;/span&gt;: &lt;span style="color:#a6e22e"&gt;execute&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;of&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;template&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;failed&lt;/span&gt;: &lt;span style="color:#a6e22e"&gt;template&lt;/span&gt;: &lt;span style="color:#a6e22e"&gt;_partials&lt;/span&gt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;head&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;html&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;37&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;40&lt;/span&gt;: &lt;span style="color:#a6e22e"&gt;executing&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;_partials/head.html&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;at&lt;/span&gt; &amp;lt;&lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Page&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Language&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Locale&lt;/span&gt;&amp;gt;: &lt;span style="color:#a6e22e"&gt;can&lt;/span&gt;&lt;span style="color:#960050;background-color:#1e0010"&gt;&amp;#39;&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;t&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;evaluate&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;field&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Locale&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;in&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;type&lt;/span&gt; &lt;span style="color:#f92672"&gt;*&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;langs&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Language&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;Error&lt;/span&gt;: &lt;span style="color:#a6e22e"&gt;Process&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;completed&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;with&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;exit&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;code&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Locally on my machine, Hugo builds successfully but issues a few warnings:&lt;/p&gt;&#10;&lt;h3 id="changes-to-language-identifiers-and-evaluation-as-of-v0158"&gt;Changes to Language Identifiers and Evaluation as of v0.158&lt;/h3&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:➜/blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;main&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ hugo build --logLevel info&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;WARN deprecated: site.Language.Locale was deprecated in Hugo v0.158.0 and will be removed in a future release. Use .Page.Language.Locale instead.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;WARN deprecated: .Language.Lang was deprecated in Hugo v0.158.0 and will be removed in a future release.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You might think these errors would be easy to fix. Unfortunately, they can be very good at hiding:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;in submodules loaded as third-party content (e.g., in themes). A project-wide search in the IDE usually excludes submodules and yields no results.&lt;/li&gt;&#10;&lt;li&gt;as variables in your own code that access the old parameters only indirectly: Up until now, I&amp;rsquo;ve been using &lt;code&gt;.Page.Site.Home.AllTranslations&lt;/code&gt; to determine if I am currently on the &amp;ldquo;main language&amp;rdquo; version of a given page. The scoping alone is illogical, and in my opinion, it is rightly being removed. What I want to achieve can now be done much more easily using &lt;code&gt;.Page.Language.IsDefault&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="updating-submodules"&gt;Updating Submodules&lt;/h3&gt;&#10;&lt;p&gt;I see two possible approaches for handling submodules:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Contact the maintainers (or open an issue) to let them know their code is becoming outdated and ask them to update their themes.&lt;/li&gt;&#10;&lt;li&gt;Copy the affected files out of the submodule and integrate them into my own folder structure. In my case, for example, the affected &lt;code&gt;language-menu.html&lt;/code&gt; would find a new home at &lt;code&gt;/layouts/partials/language-menu.html&lt;/code&gt;. I can then fix the issues there myself.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="implementing-custom-solutions"&gt;Implementing Custom Solutions&lt;/h3&gt;&#10;&lt;p&gt;If the error message doesn&amp;rsquo;t make the solution obvious, the Hugo community is a great resource. For instance, there is detailed documentation available regarding the &lt;a href="https://discourse.gohugo.io/t/deprecations-in-v0-158-0/56869" target="_blank" rel="noopener noreferrer" class="external-link"&gt;deprecations in v0.158&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="comparing-versions"&gt;Comparing Versions&lt;/h3&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-09-15-hugo-build-v0154-fail.avif" alt="Image: Gitea&amp;#39;s workflow run log in detail view. The build fails with an error about a field that cannot be evaluated."&gt;&lt;/figure&gt;&#10;&lt;p&gt;Hmm. As the image shows, my container uses the &lt;code&gt;hugo extended&lt;/code&gt; package from hugomods, which provides version &lt;code&gt;v0.154.5&lt;/code&gt;; I had selected a stable release for the Action. Let&amp;rsquo;s compare this with my local version, which now builds perfectly without a single warning:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;main&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ hugo version&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;hugo v0.162.1+extended linux/amd64&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The classic scenario: A change introduced in &lt;code&gt;v0.158&lt;/code&gt; altered the internal API. Older versions don&amp;rsquo;t recognize the new fields and throw errors. Newer versions, however, flag the use of the old fields as an issue.&lt;/p&gt;&#10;&lt;p&gt;I believe these problems can only be reliably avoided if the pipeline is identical for both local and remote environments. In other words: boot up a virtual machine on your local computer running the CI operating system and encapsulate everything within Docker. That certainly makes sense if you work in a larger organization where multiple users face this same issue.&lt;/p&gt;&#10;&lt;p&gt;To fix the problem, I switch to the &lt;code&gt;image: hugomods/hugo:latest&lt;/code&gt; version within the remote container instead. However, I still end up with version mismatches between my local setup and the CD pipeline. The latest version frankly isn&amp;rsquo;t available for my operating system yet, and the Docker release also lags slightly behind the original releases.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-09-30-CD-build-with-hugo.avif" alt="Image: Gitea dashboard in the Actions tab. We see three successful workflow runs with a hugo full build &amp;amp; deploy of my site, taking 12sec, 9sec, and 21sec."&gt;&lt;/figure&gt;&#10;&lt;p&gt;The build and release times are fantastic, aren&amp;rsquo;t they? Getting a website change online in just &lt;code&gt;12 seconds&lt;/code&gt;, including spinning up the container and so on, is something I&amp;rsquo;ve certainly never experienced before. The process takes around &lt;code&gt;20 seconds&lt;/code&gt; when it has to fetch and download container references from scratch, such as when the &lt;code&gt;latest&lt;/code&gt; tag in the registry is updated.&lt;/p&gt;&#10;&lt;p&gt;The linked article offers a detailed &lt;a href="https://blog.schallbert.de/en/hugo-versus-jekyll-benchmark/"&gt;benchmark comparing Hugo and Jekyll&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>Migrating from Jekyll to Hugo</title><link>https://blog.schallbert.de/en/projects/migrating-jekyll-to-hugo/</link><pubDate>Sun, 06 Sep 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/projects/migrating-jekyll-to-hugo/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/migrating-hugo-to-jekyll/hugo-jekyll-build-compare-cover.avif"&#10; class="post-cover"&#10; alt="Image: Hugo console build output. It shows a full build time below 8sec. (~70% reduction over build time with Jekyll)"&#10; title="Migrating from Jekyll to Hugo" /&gt;&#10;&lt;h2 id="project-profile"&gt;Project Profile&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Difficulty: Medium (3/5)&lt;/li&gt;&#10;&lt;li&gt;Cost: €0&lt;/li&gt;&#10;&lt;li&gt;Time: ~20h&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;In this project, I describe my migration from Jekyll using the &lt;a href="https://github.com/mmistakes/minimal-mistakes" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Minimal Mistakes&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; theme to Hugo, utilizing a heavily modified &lt;a href="https://github.com/panr/hugo-theme-terminal" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Terminal theme&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="why-switch-at-all"&gt;Why switch at all?&lt;/h2&gt;&#10;&lt;p&gt;For quite some time, I hadn&amp;rsquo;t been fully satisfied with building my website using Jekyll. The trigger for the switch was a day when I suddenly couldn&amp;rsquo;t build the site locally due to a compatibility issue in Ruby. Standard commands like &lt;code&gt;bundle update --conservative&lt;/code&gt; or &lt;code&gt;bundle install&lt;/code&gt; didn&amp;rsquo;t help. Even manually installing the problematic packages using commands like &lt;code&gt;gem install commonmarker-0.23.12&lt;/code&gt; or &lt;code&gt;gem install posix-spawn -v 0.3.15 -- --with-cflags=&amp;quot;-Wno-incompatible-function-pointer-types&amp;quot;&lt;/code&gt; failed to resolve the problem.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/migrating-hugo-to-jekyll/jekyll-update-bundle-installer-error.avif" alt="Image: Bundler dependency tree showing the error: Failed to build gem native extension. Multiple errors like this occurred lately when I tried building my site."&gt;&lt;/figure&gt;&#10;&lt;p&gt;Updating &lt;em&gt;Ruby&lt;/em&gt; and &lt;em&gt;Jekyll&lt;/em&gt; themselves didn&amp;rsquo;t work either. Besides, build times with Jekyll had become quite long. Since &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;moving the site from GitHub Pages to my own server&lt;/a&gt;, I am no longer tied to Jekyll, making a switch more attractive.&lt;/p&gt;&#10;&lt;p&gt;This no-build-locally problem was just the final nudge that made me begin this laborious task. I hate it when technology doesn&amp;rsquo;t do what I want - even though I admit that often times, I am the root cause for that myself.&lt;/p&gt;&#10;&lt;h3 id="further-difficulties-with-jekyll"&gt;Further difficulties with Jekyll&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;CI&amp;amp;CD: My deployment pipeline had failed months ago due to similar errors, forcing me to revert to an older version of Jekyll. This increases technical debt and the attack surface of my GitHub Actions. I would have to look for a well-maintained container solution from another provider. Searching for and setting one up would require additional time.&lt;/li&gt;&#10;&lt;li&gt;RSS feed: I wanted to provide an RSS feed for my website. However, my experiments with Jekyll showed that only &amp;ldquo;posts&amp;rdquo; were included, not &amp;ldquo;announcements.&amp;rdquo; I wanted to fix this.&lt;/li&gt;&#10;&lt;li&gt;Image formats: Jekyll normally generates cover images (thumbnails) for posts within the RSS feed. Unfortunately, this stopped working after I switched from &lt;code&gt;.jpg&lt;/code&gt; to the more space-efficient &lt;code&gt;.avif&lt;/code&gt; format. Despite several attempts, I couldn&amp;rsquo;t get a solution approved through the pull request review process.&lt;/li&gt;&#10;&lt;li&gt;Icons: The theme I was using relies on &lt;em&gt;Font Awesome&lt;/em&gt; to display icons and is quite large overall for a static website. Since I didn&amp;rsquo;t want to load assets from external sources, I stored the library locally. My plan was to select only the icons I actually needed and delete the rest. With the switch to Hugo, this is no longer necessary.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="opportunities"&gt;Opportunities&lt;/h3&gt;&#10;&lt;p&gt;I expect Hugo to deliver faster build times, a simplified CI/CD process (without the need to install Bundler, Ruby, etc.), and a resolution to the difficulties described above. Hugo&amp;rsquo;s approach to dependency and package management differs fundamentally from Jekyll&amp;rsquo;s: additional content is integrated via &lt;a href="https://git-scm.com/book/en/v2/Git-Tools-Submodules" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Git Submodules&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, and dependency management relies on &lt;a href="https://instagit.com/gohugoio/hugo/hugo-dependency-management-go-modules/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Go modules&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; rather than a &lt;a href="https://bundler.io/man/gemfile.5.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gemfile&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="the-migration-tool"&gt;The migration tool&lt;/h2&gt;&#10;&lt;p&gt;The folder structures for content differ significantly between &lt;em&gt;Jekyll&lt;/em&gt; and &lt;em&gt;Hugo&lt;/em&gt;. I am therefore using an &lt;a href="https://gohugo.io/commands/hugo_import_jekyll/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;import tool&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; included with Hugo, which correctly imports at least the articles located under &lt;code&gt;/posts&lt;/code&gt;. However, specific content such as overview pages (&lt;code&gt;/tags&lt;/code&gt;, &lt;code&gt;/pages/error&lt;/code&gt;, &lt;code&gt;/pages/legal&lt;/code&gt;), and pages hosting projects and announcements (&lt;code&gt;/_announcements&lt;/code&gt;, &lt;code&gt;/_projects&lt;/code&gt;), will not be migrated automatically and must be copied and modified manually.&lt;/p&gt;&#10;&lt;h3 id="adjusting-front-matter"&gt;Adjusting Front Matter&lt;/h3&gt;&#10;&lt;p&gt;Many of the customizations I implemented in Jekyll either do not work correctly in Hugo or cause the build process to fail. Consequently, the &lt;strong&gt;front matter&lt;/strong&gt; of every Markdown file must be adjusted and, in some cases, cleaned up.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Alt: So kann die Front matter einer Übersichtsseite in Jekyll aussehen&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;lang&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;title&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Beitragsarchiv&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;subtitle&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Diese Sammlung enthält alle meine Beiträge&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;layout&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;collection&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;collection&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;posts&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;permalink&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;/posts-archive/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;entries_layout&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;grid&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;classes&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;wide&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;author_profile&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In my case, there are several theme-specific parameters in the front matter, such as &lt;code&gt;layout&lt;/code&gt;, &lt;code&gt;classes&lt;/code&gt;, and &lt;code&gt;author_profile&lt;/code&gt;. The &lt;code&gt;lang&lt;/code&gt; parameter from my Jekyll internationalization extension isn&amp;rsquo;t even valid Hugo syntax; Hugo expects the language setting either as a field under &lt;code&gt;params: lang&lt;/code&gt; or defined at the folder level. Other custom parameters tailored to personal preference can also be defined and passed via &lt;code&gt;params&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Permalinks are no longer included in the front matter; instead, configuration is handled &lt;a href="https://gohugo.io/configuration/permalinks/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;centrally&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://gohugo.io/content-management/front-matter/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Hugo-compatible front matter&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; might look like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;title&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Post Archive&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;description&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;This collection contains all my posts&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;layout&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;collection&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="theme-configuration"&gt;Theme Configuration&lt;/h2&gt;&#10;&lt;p&gt;I can install a basic configuration for my theme using the following command:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;main&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ git submodule add https://github.com/panr/hugo-theme-terminal ./themes/terminal&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To activate it, I need to specify it in the main configuration file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /hugo.toml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Add it only if you keep the theme in the `themes` directory.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Remove it if you use the theme as a remote Hugo Module.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;theme&lt;/span&gt; = &lt;span style="color:#e6db74"&gt;&amp;#34;terminal&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And just like that, Hugo builds the site using the Terminal theme and its default colors.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/migrating-hugo-to-jekyll/hugo-initial-build-with-terminal.avif" alt="Image: One of my blog posts in original Terminal theme: Left-adjusted, filling half the screen and in dark grey, orange as contrast color, and white text."&gt;&lt;/figure&gt;&#10;&lt;p&gt;However, the layout isn&amp;rsquo;t vertically centered and is too narrow for my taste; the text is quite small, and I want &amp;ldquo;my&amp;rdquo; blog colors to be different as well. Therefore, I am creating a &lt;code&gt;.css&lt;/code&gt; file that overrides some properties of the base theme. It must be located in the &lt;code&gt;/static&lt;/code&gt; folder and named after the theme.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-css" data-lang="css"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;/* /static/terminal.css */&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;:&lt;span style="color:#a6e22e"&gt;root&lt;/span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --background: &lt;span style="color:#ae81ff"&gt;#252a34&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --background-highlight: &lt;span style="color:#ae81ff"&gt;#475064&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --foreground: &lt;span style="color:#ae81ff"&gt;#d2eaef&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --foreground-dimmed: &lt;span style="color:#ae81ff"&gt;#8b9ea2&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --accent: &lt;span style="color:#ae81ff"&gt;#25a679&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --accent-highlight: &lt;span style="color:#ae81ff"&gt;#79ae9c&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --radius: &lt;span style="color:#ae81ff"&gt;8&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;px&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --font-size: &lt;span style="color:#ae81ff"&gt;1.35&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;rem&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --line-height: &lt;span style="color:#ae81ff"&gt;1.8&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;em&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;/* Use system fonts, reduce transferred kB */&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;body&lt;/span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;font-family&lt;/span&gt;: system-ui, &lt;span style="color:#f92672"&gt;-&lt;/span&gt;apple-system, &lt;span style="color:#e6db74"&gt;&amp;#34;Segoe UI&amp;#34;&lt;/span&gt;, Roboto, Arial, &lt;span style="color:#66d9ef"&gt;sans-serif&lt;/span&gt; &lt;span style="color:#75715e"&gt;!important&lt;/span&gt;; &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;/* make content wider */&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;body&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;container&lt;/span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;max-width&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;70&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;rem&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;width&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;90&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;%&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And just like that, it looks almost like it used to.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/migrating-hugo-to-jekyll/hugo-custom-build-with-terminal.avif" alt="Image: One of my blog posts in the modified Terminal theme: centered, filling three quarters of the screen and in dark grey, teal as contrast color, and blueish-white text with a sans-serif system font."&gt;&lt;/figure&gt;&#10;&lt;h2 id="organizing-the-folder-structure"&gt;Organizing the folder structure&lt;/h2&gt;&#10;&lt;p&gt;Unlike the version of &lt;em&gt;Jekyll&lt;/em&gt; I was using, &lt;em&gt;Hugo&lt;/em&gt; features built-in internationalization (&lt;code&gt;i18n&lt;/code&gt;). This requires me to restructure my folders and explicitly sort content into &lt;code&gt;/de&lt;/code&gt; and &lt;code&gt;/en&lt;/code&gt; directories. Instead of sitting at a higher folder level, content is now stored directly within the &lt;code&gt;content&lt;/code&gt; folder—under &lt;code&gt;/_pages&lt;/code&gt;, &lt;code&gt;/_announcements&lt;/code&gt;, and &lt;code&gt;/_posts&lt;/code&gt;—which makes things much tidier.&lt;/p&gt;&#10;&lt;h3 id="before"&gt;Before&lt;/h3&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _announcements&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── assets&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── css&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── fontawesome&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── katex&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── main.scss &lt;span style="color:#75715e"&gt;# &amp;lt;-- theme import, customization&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ └── webfonts&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── js&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── lunr&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── _main.js&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── main.min.js&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── plugins&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ └── vendor&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── video&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── banner.js&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── CNAME&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _config.yml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _data&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── de&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── l10n.yml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── en&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── l10n.yml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── en&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── index.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── Gemfile&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── Gemfile.lock&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _includes &lt;span style="color:#75715e"&gt;# &amp;lt;-- Layouts (HTML), partials, 3rd-party&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── index.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- Landing page&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _layouts &lt;span style="color:#75715e"&gt;# &amp;lt;-- Page layouts&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _pages&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── 404.md &lt;span style="color:#75715e"&gt;# &amp;lt;-- German version&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── &lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── en&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── 404.md &lt;span style="color:#75715e"&gt;# &amp;lt;-- English version&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── &lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _posts/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── en &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _projects/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _sass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── minimal-mistakes &lt;span style="color:#75715e"&gt;# &amp;lt;-- Styles (SCSS) for layouts and partials&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── minimal-mistakes.scss&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── _video.scss&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── _site/ &lt;span style="color:#75715e"&gt;# &amp;lt;-- Build artifacts&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── staticman.yml&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="after"&gt;After&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://gohugo.io/templates/new-templatesystem-overview/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Hugo&amp;rsquo;s documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; provides a good overview of the folder structure. The root folders &lt;code&gt;content&lt;/code&gt;, &lt;code&gt;layouts&lt;/code&gt;, and &lt;code&gt;static&lt;/code&gt; are the most important for day-to-day work with articles and the website&amp;rsquo;s appearance.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── archetypes&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── assets&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── content&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── de&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── about.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── announcements/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── posts/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ └── projects/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── en&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── about.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── &lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── data&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── hugo.yml&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── layouts&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── _default&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── baseof.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- defines header, add-ons, content, footer structure&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── index.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- landing page&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── list.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- grid view&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ └── single.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- post view&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── _markup&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ └── render-link.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- link highlighting and function (referrer, tabs)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── _partials&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── cover.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- cover (thumbnail) image rendering&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ └── math.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- math rendering (katex)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── _shortcodes&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── audio.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── image.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── &lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── resources&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── _gen&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── assets/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── static&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── assets&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── audio/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── css/ &lt;span style="color:#75715e"&gt;# &amp;lt;-- contains overrides for custom layouts &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── docs/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── images/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── js/ &lt;span style="color:#75715e"&gt;# &amp;lt;-- math (katex) JS lives here&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ └── video/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── terminal.css &lt;span style="color:#75715e"&gt;# &amp;lt;-- Central theme override (colors, formatting, styling)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;└── themes&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; └── terminal &lt;span style="color:#75715e"&gt;# &amp;lt;-- GIT submodule: Vanilla &amp;#34;Terminal&amp;#34; theme&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With both solutions, layouts are defined in HTML and styles/formatting in CSS. However, since I had to use more plugins with Jekyll, the setup is more extensive.&lt;/p&gt;&#10;&lt;h2 id="manual-adjustments-shortcodes"&gt;Manual Adjustments: Shortcodes&lt;/h2&gt;&#10;&lt;p&gt;Hugo&amp;rsquo;s &lt;em&gt;shortcodes&lt;/em&gt; allow you to define non-text content (media, links, formatting, styles) that is then rendered according to specific rules. To do this, you create an HTML file that defines the wrapper and includes the relevant CSS classes. You also add the desired formatting to the theme&amp;rsquo;s overriding CSS file.&lt;/p&gt;&#10;&lt;p&gt;Since I am by no means an expert, I simply experiment until I am happy with the layout and no longer see any artifacts or overlapping elements.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── layouts&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── _shortcodes&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── my-shortcode.html &lt;span style="color:#75715e"&gt;# &amp;lt;-- Custom shortcode file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── static&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── terminal.css &lt;span style="color:#75715e"&gt;# &amp;lt;-- Central theme override (colors, formatting, styling)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="syntax-hugo-and-jekyll"&gt;Syntax: Hugo and Jekyll&lt;/h3&gt;&#10;&lt;p&gt;In Hugo, shortcodes are represented in the Markdown file using the following syntax:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&amp;lt; &lt;span style="color:#a6e22e"&gt;shortcode&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;html&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;filename&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;options&lt;/span&gt; &amp;gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&amp;lt; &lt;span style="color:#a6e22e"&gt;image&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;/path/to/image.avif&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;Image: alt text&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;position&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;right&amp;#34;&lt;/span&gt; &amp;gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By comparison, shortcode-like decorators in Jekyll using &lt;a href="https://jekyllrb.com/docs/liquid/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Liquid, the templating language employed by Jekyll&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; look like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-md" data-lang="md"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;### Liquid in interpreter code&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{% Liquid bracket syntax %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;### Liquid &amp;#34;shortcode&amp;#34; pendant&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{% include gallery id=&amp;#34;gallery&amp;#34; caption=&amp;#34;Eindrücke von &lt;span style="font-weight:bold"&gt;**MobFobAmp**&lt;/span&gt;. Zum Vergrößern anklicken.&amp;#34; %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;### Liquid decorator for layouting&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{:.list-inline}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="image"&gt;image&lt;/h3&gt;&#10;&lt;p&gt;Example of image display. A centered image follows on the next line.&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/favicon/logo_icon.avif" alt="Image: My blog logo, half a speaker chassis, half cog wheels, centered."&gt;&lt;/figure&gt;&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--left"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/favicon/logo_icon.avif" alt="Image: My blog logo, half a speaker chassis, half cog wheels, left-adjusted with text flow."&gt;&lt;/figure&gt;&#10;&lt;p&gt;The standard Markdown image syntax &lt;code&gt;[Alt Text](/path/to/image)&lt;/code&gt; renders as a centered image without text wrapping by default in Hugo. However, for a pleasing layout, I need a few variations that require some additional CSS and HTML code.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/favicon/logo_icon.avif" alt="Image: My blog logo, half a speaker chassis, half cog wheels, right-adjusted with text flow."&gt;&lt;/figure&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Centered image, no text wrapping&lt;/li&gt;&#10;&lt;li&gt;Left-aligned image, text wrapping on the right&lt;/li&gt;&#10;&lt;li&gt;Right-aligned image, text wrapping on the left&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;First, Hugo retrieves the file path and alt text. Then, it reads the desired formatting defined via the &lt;code&gt;position&lt;/code&gt; variable from the Markdown file. Finally, it applies the &lt;code&gt;image-wrapper&lt;/code&gt; class from the CSS file. To ensure better readability, the image alignment applies only to the desktop version of the site (&lt;code&gt;&amp;gt;684px&lt;/code&gt;).&lt;/p&gt;&#10;&lt;p&gt;The theme customizations required to display images correctly are quite extensive. I also developed a gallery feature for posts containing a large number of images. Consequently, I wrote &lt;a href="https://blog.schallbert.de/en/migrate-jekyll-to-hugo-image-alignment/"&gt;a separate article&lt;/a&gt; covering image formatting and alignment.&lt;/p&gt;&#10;&lt;h3 id="audio"&gt;audio&lt;/h3&gt;&#10;&lt;p&gt;I use standard browser-native features to display the audio player.&#10;&lt;figure class="media-frame media-frame--center media-frame--audio"&gt;&#10; &lt;figure class="media-frame"&gt;&#10; &lt;audio controls&gt;&#10; &lt;source src="https://blog.schallbert.de/assets/audio/aa_alpha_fingered_flageolet.mp3" type="audio/mp3"&gt;&#10; Your browser does not support the audio element.&#10; &lt;/audio&gt;&lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;Beispielplayer: A lead melody on electric bass&lt;/span&gt;&#10; &lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;/div&gt;&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#75715e"&gt;/* /layouts/_shortcodes/audio.html */&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{ &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;src&amp;#34;&lt;/span&gt; }}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{ &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;caption&amp;#34;&lt;/span&gt; | &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt; }}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;media-wrapper&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;media-container&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;audio&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;controls&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;source&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $src }}&amp;#34;&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;type&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;audio/mp3&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Your&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;browser&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;does&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;support&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;the&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;audio&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;element&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;audio&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{ &lt;span style="color:#a6e22e"&gt;with&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt; }}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;media-title&amp;#34;&lt;/span&gt;&amp;gt;{{ . }}&amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{ &lt;span style="color:#a6e22e"&gt;end&lt;/span&gt; }}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In &lt;code&gt;css&lt;/code&gt;, I use a wrapper and a media container to display titles within the player frame and to simplify formatting. Here, too, I have implemented custom behavior for a responsive layout on mobile devices or when the browser window is resized.&lt;/p&gt;&#10;&lt;h3 id="video"&gt;video&lt;/h3&gt;&#10;&lt;p&gt;The CSS is identical to that of the &lt;code&gt;audio&lt;/code&gt; element; there are only minor differences in the HTML. The container I use supports both &amp;ldquo;embed links&amp;rdquo; from sites like &lt;em&gt;PeerTube&lt;/em&gt; and local source files via the &lt;code&gt;src=&lt;/code&gt; attribute.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center media-frame--video"&gt;&#10; &lt;div class="media-video"&gt;&lt;video controls&gt;&#10; &lt;source src="https://blog.schallbert.de/assets/video/posts/2022-03-01_trolley.mp4" type="video/mp4"&gt;&#10; Your browser does not support the video tag.&#10; &lt;/video&gt;&lt;/div&gt;&#10; &lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;Video example (local): linear glides with ball bearing.&lt;/span&gt;&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#75715e"&gt;/* /layouts/_shortcodes/video.html */&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;src&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;title&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;title&amp;#34;&lt;/span&gt; | &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;caption&amp;#34;&lt;/span&gt; | &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;media-wrapper&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;media-container&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;hasPrefix&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;http&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;iframe&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $src }}&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;frameborder&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;0&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;allowfullscreen&lt;/span&gt;&amp;gt;&amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;iframe&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;video&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;controls&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;source&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $src }}&amp;#34;&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;type&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;video/mp4&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Your&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;browser&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;does&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;support&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;the&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;video&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;tag&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;video&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;end&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;or&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;title&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;media-title&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;title&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}{{ &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;title&lt;/span&gt; }}{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}{{ &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt; }}{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;end&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;end&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="updates"&gt;Updates&lt;/h3&gt;&#10;&lt;p&gt;My content ages, too. Sometimes I switch technologies or providers, and sometimes third parties move away from solutions I&amp;rsquo;ve described implementing here on the blog. That&amp;rsquo;s why I introduced &lt;code&gt;update-shortcodes&lt;/code&gt;.&lt;/p&gt;&#10;&lt;aside class="update-box update-box--warn" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ⚠️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Software ages&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-09-15T00:00:00Z"&gt;&#10; 2026-09-15&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; Sounds strange. How does software age? Perhaps it&amp;rsquo;s better put this way: time and the world move on, leaving software behind, so it requires constant adjustment. If a box like this appears on the blog, it means there is new information on the topic that either supplements or replaces the original content.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h2 id="links"&gt;Links&lt;/h2&gt;&#10;&lt;p&gt;I want the following behavior for links on my blog:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;internal links should open in the same tab and redirect the user.&lt;/li&gt;&#10;&lt;li&gt;internal links should point to pages in the same language, rather than leading to the default language or back to the homepage.&lt;/li&gt;&#10;&lt;li&gt;external links should be marked with an icon: &lt;code&gt;↗&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;external links should open in a new tab: &lt;code&gt;target=&amp;quot;_blank&amp;quot;&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;external links should neither access the original content nor be able to see the referrer: &lt;code&gt;rel=&amp;quot;noopener noreferrer&amp;quot;&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;To achieve this, I create the file &lt;code&gt;render-link.html&lt;/code&gt; in &lt;code&gt;/layouts/_markup&lt;/code&gt;.&#10;I present the code and the background details in a separate post: &lt;a href="https://blog.schallbert.de/en/hugo-create-language-specific-permalinks/"&gt;Hugo Multi-language: Internal Permalinks&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;The CSS override looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-css" data-lang="css"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;/* /static/terminal.css */&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;.&lt;span style="color:#a6e22e"&gt;external-link&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;external-link-icon&lt;/span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;display&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;inline-block&lt;/span&gt;; &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;font-size&lt;/span&gt;: &lt;span style="color:#a6e22e"&gt;var&lt;/span&gt;(&lt;span style="color:#f92672"&gt;--&lt;/span&gt;font&lt;span style="color:#f92672"&gt;-&lt;/span&gt;size); &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;vertical-align&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;middle&lt;/span&gt;; &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;transform&lt;/span&gt;: translateY(&lt;span style="color:#ae81ff"&gt;-0.1&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;em&lt;/span&gt;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="manual-customizations-partials"&gt;Manual Customizations: Partials&lt;/h2&gt;&#10;&lt;p&gt;You can think of partials as the layout building blocks for a Hugo website. They are stored in &lt;code&gt;/layouts/_partials&lt;/code&gt; and override the defaults of the theme being used. Since the original file is no longer loaded at all, the recommended approach is to copy the original file into the folder and then customize it manually.&lt;/p&gt;&#10;&lt;h3 id="index"&gt;index&lt;/h3&gt;&#10;&lt;p&gt;The blog&amp;rsquo;s homepage is entirely custom-written; it is not in Markdown format but in HTML. It displays its content differently than all other blog pages, so it wasn&amp;rsquo;t worth the effort for me to program specific templates. In the corresponding &lt;code&gt;/layouts/_partials/index.html&lt;/code&gt; file, I load the necessary CSS files and define the page structure. The following table provides an overview of my changes.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;File&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Purpose&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Customization&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;cover&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;thumbnail / cover image for overview pages, lists, and grids&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Add custom cover image decorator, alt text&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;head&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Defines Favicon, search console, page parameters, feeds etc.&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Custom favicon path&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;header&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Page header containing menu, logo, navigation etc.&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Add logo and subtitle to menu, lang selector&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;logo&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Custom page logo, title, subtitle&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Full&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;math&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Displays math content with Latex/Katex&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;None, implement math support&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h3 id="math"&gt;Math&lt;/h3&gt;&#10;&lt;p&gt;My blog is designed to support mathematical notation. I use &lt;a href="https://katex.org/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;KaTeX&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for this.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* /layouts/_partials/math.html */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;link&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;rel&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;stylesheet&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;/assets/css/katex.min.css&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;script&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;/assets/js/katex.min.js&amp;#34;&lt;/span&gt;&amp;gt;&amp;lt;/&lt;span style="color:#f92672"&gt;script&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;script&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;defer&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;/assets/js/contrib/auto-render.min.js&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;onload&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;renderMathInElement(document.body);&amp;#34;&lt;/span&gt;&amp;gt;&amp;lt;/&lt;span style="color:#f92672"&gt;script&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;script&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; document.&lt;span style="color:#a6e22e"&gt;addEventListener&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;DOMContentLoaded&amp;#34;&lt;/span&gt;, &lt;span style="color:#66d9ef"&gt;function&lt;/span&gt;() {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;renderMathInElement&lt;/span&gt;(document.&lt;span style="color:#a6e22e"&gt;body&lt;/span&gt;, {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;delimiters&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; [&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#a6e22e"&gt;left&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;\\[&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;right&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;\\]&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;display&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;}, &lt;span style="color:#75715e"&gt;// block&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#a6e22e"&gt;left&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;$$&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;right&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;$$&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;display&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;}, &lt;span style="color:#75715e"&gt;// block&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#a6e22e"&gt;left&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;\\(&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;right&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;\\)&amp;#39;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;display&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;}, &lt;span style="color:#75715e"&gt;// inline&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ],&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;throwOnError&lt;/span&gt; &lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; });&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; });&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;script&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="cover-image"&gt;cover image&lt;/h3&gt;&#10;&lt;p&gt;I use the term &lt;code&gt;cover&lt;/code&gt; to refer to a blog post&amp;rsquo;s title image. It is displayed on overview pages, in article and tag lists, etc., and is intended to complement the blog post&amp;rsquo;s content. My &lt;code&gt;RSS&lt;/code&gt; &lt;a href="https://blog.schallbert.de/en/index.xml/"&gt;feed&lt;/a&gt; should also use these images to provide an overview of various articles on my readers&amp;rsquo; devices.&lt;/p&gt;&#10;&lt;p&gt;It is highly compressed and requires minimal bandwidth, resulting in a low resolution (&lt;code&gt;440x220&lt;/code&gt; or &lt;code&gt;640x352&lt;/code&gt;). To ensure a consistent look, the images are also always monochrome.&lt;/p&gt;&#10;&lt;p&gt;I use the following Markdown syntax to embed the cover image:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;cover_alt&lt;/span&gt;: &lt;span style="color:#f92672"&gt;&amp;#39;Image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Hugo console build output. It shows a full build time below 2sec. (~90% reduction over build time with Jekyll)&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;cover&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;/assets/images/migrating-hugo-to-jekyll/hugo-jekyll-build-compare-cover.avif&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Next, I create the file &lt;code&gt;cover.html&lt;/code&gt; in the &lt;code&gt;layouts/_partials&lt;/code&gt; directory and add the following code:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* /layouts/_partials/cover.html */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* Handles cover-image generation */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$cover&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$autoCover&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;default&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$.Site.Params.autoCover&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;index&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Params&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;cover&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Resources.GetMatch&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Params.Cover&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$cover&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Resources.GetMatch&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Params.Cover&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.RelPermalink&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$cover&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;absURL&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Params.Cover&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$.Site.Params.AutoCover&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Params.Cover&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Resources.GetMatch&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;cover.*&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$cover&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Resources.GetMatch&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;cover.*&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.RelPermalink&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$cover&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;&amp;lt;!-- Cover image found --&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;img&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$cover&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post-cover&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Params.cover_alt&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;plainify&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;default&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Description&lt;/span&gt;&lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;plainify&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;title&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Params.CoverCredit&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;plainify&lt;/span&gt;&lt;span style="color:#f92672"&gt;|&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;default&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Title&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;plainify&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; /&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The code searches for and extracts the path to the cover image. If a valid path is found, the image is displayed as &lt;code&gt;post-cover&lt;/code&gt; with the corresponding CSS.&lt;/p&gt;&#10;&lt;p&gt;I insert the cover image into the XML template as the first element within the &lt;code&gt;&amp;lt;description&amp;gt;&lt;/code&gt; tag; the actual article follows only after that. Most feed readers then use this image as the cover for their overview lists.&lt;/p&gt;&#10;&lt;h2 id="manual-adjustments-layouts"&gt;Manual Adjustments: Layouts&lt;/h2&gt;&#10;&lt;p&gt;To ensure my Hugo site continues to feel very similar to my previous Jekyll site, I need to make a few changes to the layouts. I have described them below.&lt;/p&gt;&#10;&lt;h3 id="baseofhtml"&gt;baseof.html&lt;/h3&gt;&#10;&lt;p&gt;This is where the base content is defined. Definitions for the header and footer are imported, and stylesheets are linked. The vast majority of the changes described above are imported here.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* /layouts/baseof.html */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;html&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;lang&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$.Site.Language&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;head&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Param&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;math&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;partialCached&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;math.html&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;block&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;title&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;title&lt;/span&gt;&amp;gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.IsHome&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$.Site.Title&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Title&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt; :: &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$.Site.Title&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;title&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;partial&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;head.html&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;link&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;rel&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;stylesheet&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/assets/css/gallery.css&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;relURL&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&amp;gt; &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;link&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;rel&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;stylesheet&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/assets/css/media.css&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;relURL&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;head&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;body&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$container&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;cond&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;$.Site.Params.FullWidthTheme&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;default&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;container full&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;cond&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;$.Site.Params.CenterTheme&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;default&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;container center&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;container&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$container&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;cond&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;$.Site.Params.oneHeadingSize&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;default&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34; headings--one-size&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;partial&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;header.html&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;content&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;block&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;main&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;block&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;footer&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;partial&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;footer.html&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;body&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;html&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="listhtml"&gt;list.html&lt;/h3&gt;&#10;&lt;p&gt;I need a list view for overview pages such as &lt;code&gt;Articles&lt;/code&gt;, &lt;code&gt;Tags&lt;/code&gt;, and &lt;code&gt;Projects&lt;/code&gt;. I want to display four items per row. Each item gets its own &amp;ldquo;card&amp;rdquo; featuring a cover image, title, short description, and, if available, additional details like reading time. Hugo&amp;rsquo;s &lt;code&gt;paginator&lt;/code&gt; crawls the relevant folder and compiles the content, while my &lt;code&gt;list.css&lt;/code&gt; stylesheet handles the formatting and display.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* /layouts/_default/list.html */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;define&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;main&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;link&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;rel&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;stylesheet&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/assets/css/list.css&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;relURL&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;with&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Content&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;index-content&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;posts posts-grid&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Paginator.Pages&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;article&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post on-list&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;a&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Permalink&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post-card&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;aria-label&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Title&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;partial&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;cover.html&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post-card-body&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;h4&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post-title&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Title&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;markdownify&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;h4&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post-excerpt&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Description&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;p&lt;/span&gt;&amp;gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Description&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;p&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;and&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Param&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;readingTime&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;eq&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Param&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;readingTime&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post-reading-time&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.ReadingTime&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$.Site.Params.minuteReadingTime&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;default&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;min read&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;a&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;article&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;partial&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;pagination.html&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="singlehtml"&gt;single.html&lt;/h3&gt;&#10;&lt;p&gt;My &lt;code&gt;single.html&lt;/code&gt; is almost identical to the default implementation found in &lt;a href="https://github.com/panr/hugo-theme-terminal/blob/master/layouts/_default/single.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;terminal&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. I only added the option to include a &amp;ldquo;banner-image&amp;rdquo; associated with the title and imported the &lt;code&gt;_partial/cover.html&lt;/code&gt; snippet to load a cover image.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;article&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;with&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Params.banner&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;img&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;post-banner&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$.Params.banner_alt&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;plainify&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;default&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39; &amp;#39;&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Given all these changes, one might consider forking the theme to create a custom version&amp;hellip; 🤔&lt;/p&gt;&#10;&lt;h2 id="quality-control"&gt;Quality Control&lt;/h2&gt;&#10;&lt;p&gt;Another new addition to my website is a check for broken links. The site has grown up and now contains so many internal and external links that manual checking is no longer practical. The article on &lt;a href="https://blog.schallbert.de/en/hugo-link-checker/"&gt;&lt;code&gt;htmltest&lt;/code&gt; configuration for Hugo&lt;/a&gt; documents the tool I use and how I account for Hugo-specific quirks during the process.&lt;/p&gt;&#10;&lt;h2 id="publishing"&gt;Publishing&lt;/h2&gt;&#10;&lt;p&gt;I also need an automated pipeline for Hugo. Whenever I push a change to the &lt;code&gt;main&lt;/code&gt; branch, the website should be automatically rebuilt and deployed. I describe exactly how this works in the article &lt;a href="https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/"&gt;Building and Deploying a Hugo Website with GitHub Actions and Docker&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="performance"&gt;Performance&lt;/h2&gt;&#10;&lt;p&gt;What has all this effort achieved? For &lt;em&gt;users&lt;/em&gt;, the appearance and navigation remain very similar to the Jekyll-based site. However, the improvements are evident in faster load times, reduced data transfer, and better overall clarity. I provide a detailed comparison in the article &lt;a href="https://blog.schallbert.de/en/hugo-versus-jekyll-benchmark/"&gt;&lt;em&gt;Jekyll&lt;/em&gt; vs. &lt;em&gt;Hugo&lt;/em&gt; Benchmarking&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>Image alignment and formatting in Hugo</title><link>https://blog.schallbert.de/en/migrate-jekyll-to-hugo-image-alignment/</link><pubDate>Sat, 29 Aug 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/migrate-jekyll-to-hugo-image-alignment/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-08-29-image-positioning-cover.avif"&#10; class="post-cover"&#10; alt="Image: A screenshot of my blog, showing three images, one each aligned left, center, right. The centered image has no text flow."&#10; title="Image alignment and formatting in Hugo" /&gt;&#10;&lt;h2 id="why-do-this"&gt;Why do this?&lt;/h2&gt;&#10;&lt;p&gt;My theme cannot derive image formatting or positioning from standard Markdown syntax like &lt;code&gt;![Image Alt Text](/path/to/image/)&lt;/code&gt;. However, I need this capability to place images aligned left, center, or right, with or without text wrap. So, I have to take matters into my own hands and code a solution myself.&lt;/p&gt;&#10;&lt;h2 id="getting-started-with-shortcodes-layouts-and-css"&gt;Getting started with shortcodes, layouts, and CSS&lt;/h2&gt;&#10;&lt;p&gt;I get an overview at &lt;a href="https://cshire.xyz/centering-images-in-hugo/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;cshire&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and quickly realize that my initial attempt using &lt;code&gt;/layouts/render-image.html&lt;/code&gt; isn&amp;rsquo;t working.&lt;/p&gt;&#10;&lt;h3 id="debugging"&gt;Debugging&lt;/h3&gt;&#10;&lt;p&gt;This method lets me determine whether &lt;code&gt;render-image.html&lt;/code&gt; is actually being read and applied—independent of any CSS:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-html" data-lang="html"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;style&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;border: 3px dashed red; padding: 10px; margin: 10px; font-weight: bold;&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;RENDER-IMAGE.HTML IS BEING READ!&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That works. So, Hugo must be missing something during the Markdown processing stage.&lt;/p&gt;&#10;&lt;h3 id="troubleshooting"&gt;Troubleshooting&lt;/h3&gt;&#10;&lt;p&gt;I can see now that Hugo is rendering the layout but isn&amp;rsquo;t processing my images correctly. Why? Because a trigger, called &lt;em&gt;shortcode&lt;/em&gt;, is missing. In Hugo, shortcodes are created in Markdown using &lt;code&gt;{{ &amp;lt;shortcode-name&amp;gt; parameter1=&amp;quot;String&amp;quot; parametern=&amp;quot;string&amp;quot; }}&lt;/code&gt;, processed via the corresponding template file &lt;code&gt;/layouts/shortcodes/&amp;lt;shortcode-name&amp;gt;.html&lt;/code&gt;, and finally displayed based on CSS styling.&lt;/p&gt;&#10;&lt;h2 id="shortcode-imagehtml"&gt;Shortcode: image.html&lt;/h2&gt;&#10;&lt;p&gt;So, I create a shortcode template that allows me to pass a few extra parameters to the image beyond just the source file (&lt;code&gt;src&lt;/code&gt;) and alt text (&lt;code&gt;alt&lt;/code&gt;): &lt;code&gt;position&lt;/code&gt;, &lt;code&gt;caption&lt;/code&gt;, &lt;code&gt;attrid&lt;/code&gt;, and &lt;code&gt;attrlink&lt;/code&gt;. The image position and caption are self-explanatory. I wanted a way to include attribution for images I didn&amp;rsquo;t create myself. For these, I either access open content governed by terms such as Creative Commons or obtain prior written consent from the copyright holder.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#75715e"&gt;/* layouts/_shortcodes/image.html */&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;src&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;alt&amp;#34;&lt;/span&gt; | &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;position&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;position&amp;#34;&lt;/span&gt; | &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;center&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;caption&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;attrid&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;attrid&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;attrlink&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;attrlink&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#a6e22e"&gt;figure&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;media-frame media-frame--{{ $position }}&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;img&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $src }}&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $alt }}&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;figcaption&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;media-caption&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;span&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;caption-text&amp;#34;&lt;/span&gt;&amp;gt;{{ &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt; }}&amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;span&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;and&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;attrid&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;attrlink&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;a&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $attrlink }}&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;attr-link&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;aria&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;label&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;Attribution {{ $attrid }}&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;sup&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;attr-id&amp;#34;&lt;/span&gt;&amp;gt;[{{ &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;attrid&lt;/span&gt; }}]&amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;sup&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;a&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;end&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;figcaption&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;end&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;figure&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This template reads the parameters provided via the shortcode and wraps the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag in a &lt;code&gt;&amp;lt;figure class=&amp;quot;media-frame&amp;quot;&amp;gt;&lt;/code&gt; container, which holds the positioning information. If an image caption and/or attribution is provided, it is displayed within a &lt;code&gt;&amp;lt;figcaption class=&amp;quot;media-caption&amp;quot;&amp;gt;&lt;/code&gt; element.&lt;/p&gt;&#10;&lt;h3 id="using-the-shortcode"&gt;Using the shortcode&lt;/h3&gt;&#10;&lt;p&gt;In Markdown, a fully configured image would look like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt;&lt;span style="color:#960050;background-color:#1e0010"&gt;&amp;lt;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;image&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;/path/to/image.jpg&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Example placeholder image for shortcode demonstration purposes&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;caption&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Shortcode demo image&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;attrid&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;1&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;attrlink&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;https://example.com/&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;position&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;right&amp;#34;&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;&amp;gt;&lt;/span&gt;&lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="migration"&gt;Migration&lt;/h3&gt;&#10;&lt;p&gt;But how do I convert the &lt;code&gt;![alt](/path/to/image) {: .align-center}&lt;/code&gt; syntax I previously used in Jekyll into the format shown above?&lt;/p&gt;&#10;&lt;p&gt;With a script! To do this, I&amp;rsquo;m creating a Python program that scans through all my articles, looking for images using the aforementioned formatting. When it finds a match, it extracts the &lt;code&gt;src&lt;/code&gt;, &lt;code&gt;alt&lt;/code&gt;, and &lt;code&gt;position&lt;/code&gt; attributes and replaces the entire expression with the equivalent Hugo shortcode.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; os&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; re&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;from&lt;/span&gt; pathlib &lt;span style="color:#f92672"&gt;import&lt;/span&gt; Path&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# spaces/tabs only, no newlines&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;SPACES &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;r&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;[ \t]*&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;IMG_WITH_OPTIONAL_ALIGN &lt;span style="color:#f92672"&gt;=&lt;/span&gt; re&lt;span style="color:#f92672"&gt;.&lt;/span&gt;compile(&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;r&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;!\[([^\]]*?)\]\(([^)]*?)\)&amp;#39;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# alt, src&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;+&lt;/span&gt; SPACES &lt;span style="color:#f92672"&gt;+&lt;/span&gt; &lt;span style="color:#75715e"&gt;# optional spaces/tabs before decorator&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;r&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;(?:\{:&amp;#39;&lt;/span&gt; &lt;span style="color:#f92672"&gt;+&lt;/span&gt; SPACES &lt;span style="color:#f92672"&gt;+&lt;/span&gt; &lt;span style="color:#75715e"&gt;# literal &amp;#34;{:&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;r&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;[^}\n]*?\.align-(center|left|right)\b[^}\n]*?&amp;#39;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# decorator body (no newlines)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;r&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;\})?&amp;#39;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# optional &amp;#34;}&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;convert_image_alignments&lt;/span&gt;(directory):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;repl&lt;/span&gt;(m):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; alt &lt;span style="color:#f92672"&gt;=&lt;/span&gt; m&lt;span style="color:#f92672"&gt;.&lt;/span&gt;group(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)&lt;span style="color:#f92672"&gt;.&lt;/span&gt;replace(&lt;span style="color:#e6db74"&gt;&amp;#39;&amp;#34;&amp;#39;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#39;&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; src &lt;span style="color:#f92672"&gt;=&lt;/span&gt; m&lt;span style="color:#f92672"&gt;.&lt;/span&gt;group(&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; position &lt;span style="color:#f92672"&gt;=&lt;/span&gt; m&lt;span style="color:#f92672"&gt;.&lt;/span&gt;group(&lt;span style="color:#ae81ff"&gt;3&lt;/span&gt;) &lt;span style="color:#f92672"&gt;or&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# return built Hugo shortcode. Escape Python f-string curly brace { with {{.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#e6db74"&gt;f&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;{{{{&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;lt; image src=&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;{&lt;/span&gt;src&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34; alt=&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;{&lt;/span&gt;alt&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34; position=&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;{&lt;/span&gt;position&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34; &amp;gt;&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;}}}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; root, _, files &lt;span style="color:#f92672"&gt;in&lt;/span&gt; os&lt;span style="color:#f92672"&gt;.&lt;/span&gt;walk(directory):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; file &lt;span style="color:#f92672"&gt;in&lt;/span&gt; files:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;not&lt;/span&gt; file&lt;span style="color:#f92672"&gt;.&lt;/span&gt;endswith(&lt;span style="color:#e6db74"&gt;&amp;#39;.md&amp;#39;&lt;/span&gt;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;continue&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; filepath &lt;span style="color:#f92672"&gt;=&lt;/span&gt; Path(root) &lt;span style="color:#f92672"&gt;/&lt;/span&gt; file&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; content &lt;span style="color:#f92672"&gt;=&lt;/span&gt; filepath&lt;span style="color:#f92672"&gt;.&lt;/span&gt;read_text(encoding&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;utf-8&amp;#39;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; new_content &lt;span style="color:#f92672"&gt;=&lt;/span&gt; IMG_WITH_OPTIONAL_ALIGN&lt;span style="color:#f92672"&gt;.&lt;/span&gt;sub(repl, content)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; new_content &lt;span style="color:#f92672"&gt;!=&lt;/span&gt; content:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; filepath&lt;span style="color:#f92672"&gt;.&lt;/span&gt;write_text(new_content, encoding&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#39;utf-8&amp;#39;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; print(&lt;span style="color:#e6db74"&gt;f&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Updated: &lt;/span&gt;&lt;span style="color:#e6db74"&gt;{&lt;/span&gt;filepath&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; __name__ &lt;span style="color:#f92672"&gt;==&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;__main__&amp;#34;&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; project_root &lt;span style="color:#f92672"&gt;=&lt;/span&gt; input(&lt;span style="color:#e6db74"&gt;&amp;#34;Enter the path to your project root: &amp;#34;&lt;/span&gt;)&lt;span style="color:#f92672"&gt;.&lt;/span&gt;strip()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; os&lt;span style="color:#f92672"&gt;.&lt;/span&gt;path&lt;span style="color:#f92672"&gt;.&lt;/span&gt;isdir(project_root):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; convert_image_alignments(project_root)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; print(&lt;span style="color:#e6db74"&gt;&amp;#34;Conversion complete!&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; print(&lt;span style="color:#e6db74"&gt;&amp;#34;Invalid directory path.&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I enlisted the help of the large-language model &lt;a href="https://mistral.ai/news/mistral-small-4/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Mistral&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for the regexes, but otherwise, the script is kept simple.&lt;/p&gt;&#10;&lt;p&gt;In Jekyll, images with titles and/or attribution were declared as &lt;code&gt;figure&lt;/code&gt; elements. I am writing a very similar script for these as well and running it during the migration process.&lt;/p&gt;&#10;&lt;h2 id="css"&gt;CSS&lt;/h2&gt;&#10;&lt;p&gt;Let&amp;rsquo;s move on to image display. I am creating a dedicated CSS file, &lt;code&gt;/assets/css/media.css&lt;/code&gt;, designed to standardize the size and formatting of images as well as other embedded media such as audio and video files for both mobile and desktop views. To achieve this, it utilizes the &lt;code&gt;class&lt;/code&gt; attributes defined in the template.&lt;/p&gt;&#10;&lt;p&gt;Here is an excerpt from the file to illustrate: this section defines the behavior of the &lt;code&gt;media-frame&lt;/code&gt; object. Each asset (image, video, or audio) is embedded within a frame that provides the necessary breathing room for the display. The &lt;code&gt;position&lt;/code&gt; attribute determines the placement and presentation style.&lt;/p&gt;&#10;&lt;p&gt;By default, the content is centered and displayed at its native resolution, provided it does not exceed the maximum text width.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-css" data-lang="css"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;.&lt;span style="color:#a6e22e"&gt;media-frame&lt;/span&gt; &lt;span style="color:#f92672"&gt;img&lt;/span&gt;&lt;span style="color:#f92672"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;.&lt;span style="color:#a6e22e"&gt;media-frame&lt;/span&gt; &lt;span style="color:#f92672"&gt;audio&lt;/span&gt;&lt;span style="color:#f92672"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;.&lt;span style="color:#a6e22e"&gt;media-frame&lt;/span&gt; &lt;span style="color:#f92672"&gt;video&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;display&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;block&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;width&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;auto&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;max-width&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;100&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;%&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;height&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;auto&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;padding&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;border&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;border-radius&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;.&lt;span style="color:#a6e22e"&gt;media-frame--center&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin-left&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;auto&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin-right&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;auto&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;/* Left/Right align only for Non-mobile use */&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;@&lt;span style="color:#66d9ef"&gt;media&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#f92672"&gt;min-width&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#f92672"&gt;768px&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; .&lt;span style="color:#a6e22e"&gt;media-frame--left&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;float&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;left&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;max-width&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;60&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;%&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin-top&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0.35&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;em&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin-right&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;rem&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin-bottom&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0.75&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;rem&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; .&lt;span style="color:#a6e22e"&gt;media-frame--right&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;float&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;right&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;max-width&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;60&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;%&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin-top&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0.35&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;em&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin-left&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;rem&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;margin-bottom&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0.75&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;rem&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Images aligned to one side have their width restricted to allow text to flow around them. Additionally, minimum margins relative to other objects are defined to prevent frames from touching when media elements appear in succession. In the mobile view, space is so limited that images are always displayed centered.&lt;/p&gt;&#10;&lt;h2 id="gallery"&gt;Gallery&lt;/h2&gt;&#10;&lt;p&gt;To display multiple images in a single location, I create a &lt;code&gt;gallery&lt;/code&gt; shortcode along with the corresponding CSS. The gallery displays images in a grid. Hovering the mouse over an image makes it appear brighter than the others. Clicking an image displays a large version of it at the bottom of the gallery, reusing the &lt;code&gt;media&lt;/code&gt; shortcode described earlier.&lt;/p&gt;&#10;&lt;p&gt;The images to be displayed are defined as a list—specifying &lt;code&gt;src&lt;/code&gt; and &lt;code&gt;alt&lt;/code&gt; attributes—within the Markdown document&amp;rsquo;s front matter.&#10;Here is an excerpt from the template used to process the shortcode.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#a6e22e"&gt;section&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;hugo-gallery&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;hugo-gallery__frame&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;hugo-gallery__grid&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;role&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;list&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;range&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;i&lt;/span&gt;, &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;img&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;items&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;img&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;img&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt; | &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt; (&lt;span style="color:#a6e22e"&gt;printf&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;Gallery image %d&amp;#34;&lt;/span&gt; (&lt;span style="color:#a6e22e"&gt;add&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;i&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)) &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;thumbURL&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;p&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;TrimPrefix&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;src&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/assets/&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;with&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;resources&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Get&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;p&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;thumb&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; .&lt;span style="color:#a6e22e"&gt;Fit&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;400x220&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;thumbURL&lt;/span&gt; = &lt;span style="color:#960050;background-color:#1e0010"&gt;$&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;thumb&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;RelPermalink&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;end&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;a&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;hugo-gallery__thumb&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;#gallery-full-{{ $i }}&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;aria&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;label&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $alt }}&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#a6e22e"&gt;img&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;src&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $thumbURL }}&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;alt&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;{{ $alt }}&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;hugo-gallery__thumb-img&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;loading&lt;/span&gt;=&lt;span style="color:#e6db74"&gt;&amp;#34;lazy&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;a&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {{&lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;end&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt;}}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I iterate through all the images defined in the front matter, limit their size to a manageable &lt;code&gt;400x220px&lt;/code&gt;, and arrange them in a grid using the class &lt;code&gt;hugo-gallery__grid&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="tests"&gt;Tests&lt;/h2&gt;&#10;&lt;p&gt;Creating the templates was relatively quick. However, I&amp;rsquo;m constantly at odds with CSS. Some override from another file keeps getting in the way, or I end up conflicting with the styles in the base CSS. That&amp;rsquo;s why I need a place to test formatting and layout.&lt;/p&gt;&#10;&lt;p&gt;So, I created an image test page for myself. Curious?&#10;&lt;a href="https://blog.schallbert.de/en/hugo-testing-image-alignment/"&gt;Check how media is displayed in Hugo&lt;/a&gt;&lt;/p&gt;&#10;</description></item><item><title>htmltest configuration ideas for Hugo</title><link>https://blog.schallbert.de/en/hugo-link-checker/</link><pubDate>Mon, 10 Aug 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/hugo-link-checker/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-08-10-htmltest-success-output-cover.avif"&#10; class="post-cover"&#10; alt="Image: An SSH public key randomart image as visual fingerprint for humans to quickly see if public keys match"&#10; title="htmltest configuration ideas for Hugo" /&gt;&#10;&lt;p&gt;I am now nearly finished &lt;a href="https://blog.schallbert.de/en/projects/migrating-jekyll-to-hugo/"&gt;migrating my website to Hugo&lt;/a&gt;. I had to make several adjustments to the front matter of each article so that they would function in Hugo much as they did in Jekyll. Changes to the folder structure, partly from the migration itself and partly from my experience as an author, meant I had to revise links in practically every article along the way. I now have nearly 340 of them, counting translations.&lt;/p&gt;&#10;&lt;p&gt;I cannot manage the sheer volume of links without some assistance. So, I need a tool.&lt;/p&gt;&#10;&lt;h2 id="htmltest"&gt;htmltest&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/wjdp/htmltest" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Htmltest&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is a utility that scans HTML files for issues. Among other things, it tracks down broken links. I install it using&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Red Hat-based Linux package manager install command&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sudo dnf install htmltest&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and run it against the HTML files built by Hugo in the &lt;code&gt;/public&lt;/code&gt; directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;htmltest public/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;✘✘✘ failed in 2.890119823s&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;2612&lt;/span&gt; errors in &lt;span style="color:#ae81ff"&gt;337&lt;/span&gt; documents&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That is quite a lot of errors! Furthermore, the way the information is presented seems rather cluttered to me: see the image.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-08-10-htmltest-error-note.avif" alt="Image: Output of a htmltest report in the console. It is completely filled with text. There are different error messages like &amp;#39;name resolution error&amp;#39;, &amp;#39;missing trailing slash&amp;#39; and so on. It is nowhere easy to read but mentions the source path of the issue."&gt;&lt;/figure&gt;&#10;&lt;h3 id="redirecting-output-to-a-file"&gt;Redirecting output to a file&lt;/h3&gt;&#10;&lt;p&gt;Consequently, I am finding it quite difficult to work with this tool at first. I had assumed a few links might have broken, but I certainly didn&amp;rsquo;t expect thousands of errors. So I fell victim to the typical &amp;ldquo;not-invented-here&amp;rdquo; syndrome and programmed my own tool in Python, which, in hindsight, turned out not to be good enough. In short:&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;Htmltest&lt;/em&gt; is a good and incredibly fast tool. If you make its output readable and interpret it correctly, you can be quite happy with it. After a bit of experimentation, I arrived at the following command:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;htmltest public/ 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt; | ansi2html &amp;gt; htmltest-output.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;blockquote&gt;&#10;&lt;p&gt;Meaning: &amp;ldquo;Run &lt;em&gt;htmltest&lt;/em&gt; in the &lt;code&gt;/public/&lt;/code&gt; folder. Redirect errors from &lt;code&gt;stderr&lt;/code&gt; to &lt;code&gt;stdout&lt;/code&gt;. Pipe the output to &lt;em&gt;ansi2html&lt;/em&gt;&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;. Write its output to a file named &lt;code&gt;htmltest-output.html&lt;/code&gt;.&amp;rdquo;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-08-10-htmltest-ansi2html-output.avif" alt="Image: Output of a htmltest report in html after piping through ansi2html, opened with a browser. The lines are formatted properly and have colour coding. Looks neat."&gt;&lt;/figure&gt;&#10;&lt;p&gt;That already looks much better!&lt;/p&gt;&#10;&lt;h3 id="error-categories"&gt;Error categories&lt;/h3&gt;&#10;&lt;p&gt;For my purposes, the errors found by &lt;em&gt;htmltest&lt;/em&gt; can be sorted into a few categories:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Hugo-specific errors, such as &amp;ldquo;target does not exist&amp;rdquo; messages caused by &lt;code&gt;livereload.js&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Incomplete links that work in practice but trigger &amp;ldquo;href lacks trailing slash&amp;rdquo; errors. These types of errors account for the lion&amp;rsquo;s share of the reported issues.&lt;/li&gt;&#10;&lt;li&gt;Lookup errors (&lt;code&gt;GET &amp;lt;src&amp;gt; [...] failure in name resolution&lt;/code&gt;) caused by a lack of connection, insufficient permissions, active bot protection, or the target server being offline.&lt;/li&gt;&#10;&lt;li&gt;Hard &amp;ldquo;target does not exist&amp;rdquo; errors caused by broken links. These are the specific ones I actually wanted to fix.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="cleaning-up-the-log"&gt;Cleaning up the log&lt;/h3&gt;&#10;&lt;p&gt;How do I ensure only category 4 errors are displayed? By making a few changes to the tools and resolving the trailing-slash errors.&lt;/p&gt;&#10;&lt;p&gt;Category 1 errors, triggered by Hugo&amp;rsquo;s useful &lt;code&gt;livereload&lt;/code&gt; function, look like this:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;target does not exist --- en/tags/tools/index.html --&amp;gt; /livereload.js?mindelay=10&amp;amp;v=2&amp;amp;port=1313&amp;amp;path=livereload&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;LiveReload is the feature that saves me from constantly hitting &lt;code&gt;F5&lt;/code&gt; in the browser window whenever Hugo builds the site after I save a Markdown file. However, I can temporarily disable it for the test. The following solution reduces the number of generated errors by one per HTML page:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;hugo server --disableLiveReload&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I avoid category 3 lookup errors by focusing on internal links, over which I have complete control. The &lt;code&gt;-s, --skip-external&lt;/code&gt; parameter in &lt;em&gt;htmltest&lt;/em&gt; helps with this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;htmltest -s public/ 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt; | ansi2html &amp;gt; htmltest-output.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;✘✘✘ failed in 494.7678ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;446&lt;/span&gt; errors in &lt;span style="color:#ae81ff"&gt;337&lt;/span&gt; documents&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I can fix broken external links later. For now, I want to resolve the issues caused by the migration.&lt;/p&gt;&#10;&lt;h2 id="missing-trailing-slash-fixing-errors-via-markup"&gt;&amp;ldquo;Missing trailing slash&amp;rdquo;: Fixing errors via markup&lt;/h2&gt;&#10;&lt;p&gt;I struggled for a long time with the many &amp;ldquo;href lacks trailing slash&amp;rdquo; errors, as I would have had to append a &lt;code&gt;/&lt;/code&gt; to almost every Markdown link in every article. Fortunately, I found a solution in my &lt;code&gt;render-link.html&lt;/code&gt; markup:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* /layouts/_markup/render-link.html */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Destination&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;safeURL&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_external&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;or&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;http://&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;https://&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_anchor&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;#&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_asset&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/assets/&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[...]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* Add / to internal paths that have no file extension */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;and&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_external&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_anchor&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_asset&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$parsed&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;urls&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Parse&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;and&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$parsed&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasSuffix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$parsed&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Path&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;))&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;eq&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;path&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Ext&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$parsed&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Path&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;printf&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;%s/&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[...]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;blockquote&gt;&#10;&lt;p&gt;Meaning: Internal links that point to neither headings (&lt;code&gt;is_anchor&lt;/code&gt;) nor media files (&lt;code&gt;is_asset&lt;/code&gt;) have a &lt;code&gt;/&lt;/code&gt; appended if, and only if, they have valid syntax (&lt;code&gt;parsed&lt;/code&gt;) and do not already end with a slash.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;This resolves 95% of the error messages, resulting in cleaner HTML. I handle the remaining 5% by adding a few slashes in the Hugo configuration. Examples:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# hugo.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;url&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;/en/&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# /en -&amp;gt; /en/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;logoHomeLink&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;/en/&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# /en -&amp;gt; /en/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Done! All that remain are hard internal errors.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;htmltest -s public/ 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt; | ansi2html &amp;gt; htmltest-output.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;✘✘✘ failed in 359.28ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;102&lt;/span&gt; errors in &lt;span style="color:#ae81ff"&gt;84&lt;/span&gt; documents&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="manual-search-and-fix"&gt;Manual Search and Fix&lt;/h2&gt;&#10;&lt;p&gt;What follows is purely a matter of tedious legwork. I open the target document (the one being linked to) and check for inconsistencies, such as heading links that differ between languages. If I find any, I add specific anchor links to the heading.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-md" data-lang="md"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;### Heading {#heading3}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If everything is fine in the target file, I then open all the source files that use the broken link and correct it manually. In most cases, the target file has moved to a different folder, or the language prefix is incorrect.&lt;/p&gt;&#10;&lt;h2 id="the-result"&gt;The Result&lt;/h2&gt;&#10;&lt;p&gt;After a few hours of exhausting, lab manual work, I am rewarded:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;htmltest public/ -s 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Skipping the checking of external links.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;htmltest started at 10:51:23 on public&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;========================================================================&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;✔✔✔ passed in 238.970354ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;tested &lt;span style="color:#ae81ff"&gt;338&lt;/span&gt; documents&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Soon I&amp;rsquo;ll be able to go online with my new-old site!&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;&lt;a href="https://packages.debian.org/en/sid/colorized-logs" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ansi2html (or colorized-logs)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is a utility that converts console output to HTML, including formatting and text colors.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>Hugo Multi-Lang: Internal Permalinks</title><link>https://blog.schallbert.de/en/hugo-create-language-specific-permalinks/</link><pubDate>Mon, 27 Jul 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/hugo-create-language-specific-permalinks/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-07-07-defaultcontentlanguage-in-hugo-config.avif"&#10; class="post-cover"&#10; alt="Image: A section in the hugo.yml file setting defaultContentLanguage and related parameters."&#10; title="Hugo Multi-Lang: Internal Permalinks" /&gt;&#10;&lt;p&gt;Anyone building a multilingual blog with Hugo might be familiar with the following problem:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Internal links in articles across different languages point only to pages in the default language.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;h2 id="configuration"&gt;Configuration&lt;/h2&gt;&#10;&lt;p&gt;The issue stems from the following setting:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# hugo.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;defaultContentLanguage&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;de&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;defaultContentLanguageInSubdir&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;disableDefaultLanguageRedirect&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My default language is German, and the corresponding articles are built into the blog&amp;rsquo;s root directory. No redirect to the &lt;code&gt;/de&lt;/code&gt; language directory takes place.&lt;/p&gt;&#10;&lt;p&gt;The directory structure is as follows:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;├── content&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── de&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── about.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── announcements&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── legal.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── posts&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ ├── privacy.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ │ └── projects&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── en&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── about.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── legal.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── posts&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ ├── privacy.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;│ └── projects&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="the-problem-with-the-default-language"&gt;The Problem with the Default Language&lt;/h2&gt;&#10;&lt;p&gt;I make articles in my default language accessible via URLs like &lt;code&gt;example.com/my-post&lt;/code&gt; and &lt;code&gt;example.com/my-2nd-post/&lt;/code&gt; by using the &lt;a href="https://gohugo.io/content-management/urls/#slug" target="_blank" rel="noopener noreferrer" class="external-link"&gt;slug&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; parameter.&#10;In the secondary language, however, they appear as &lt;code&gt;example.com/en/my-post&lt;/code&gt; and &lt;code&gt;example.com/en/my-2nd-post/&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;If I want to link two articles internally, the link always points to the article in my default language.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# English version of my post&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# example.com/en/my-post&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;slug&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;my-post&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;Post&amp;#39;s English content&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#ae81ff"&gt;2nd](/my-2nd-post)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As a result, instead of the desired &lt;code&gt;/en/my-2nd-post&lt;/code&gt;, I end up at the German version: &lt;code&gt;/my-2nd-post&lt;/code&gt;. I find this annoying, as it would require me to manually adjust every link in every article. Unfortunately, Hugo does not seem to provide a built-in solution for this. So, I am creating a rule for how links should be rendered:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* /layouts/_markup/render-link.html */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="solution-language-dependent-link-prefixes"&gt;Solution: Language-Dependent Link Prefixes&lt;/h2&gt;&#10;&lt;p&gt;Here is what needs to happen: When a relative link within the site is encountered during the build process, a language prefix (such as &lt;code&gt;/en&lt;/code&gt;) should be prepended to the link &lt;em&gt;only if&lt;/em&gt; the language of the page being rendered is not the default language (since I do not need a language prefix for the default language, given that &lt;code&gt;defaultContentLanguageInSubdir&lt;/code&gt; is set to &lt;code&gt;false&lt;/code&gt;).&lt;/p&gt;&#10;&lt;h3 id="retrieving-the-default-language"&gt;Retrieving the Default Language&lt;/h3&gt;&#10;&lt;p&gt;But how do I access the default language? The &lt;code&gt;defaultContentLanguage&lt;/code&gt; parameter is not accessible within templates. After some research, I found a suitable solution to the problem in the &lt;a href="https://discourse.gohugo.io/t/is-there-a-way-to-retrieve-the-value-of-defaultcontentlanguage/9643" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Hugo community&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;: The default language is usually the first language in the weight-based index (which determines the &lt;a href="https://gohugo.io/configuration/languages/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;order in which languages appear&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;): &lt;code&gt;index .Page.Site.Home.AllTranslations 0&lt;/code&gt;. When I access this, the &amp;ldquo;first&amp;rdquo; one is my default language.&lt;/p&gt;&#10;&lt;h3 id="code-for-render-linkhtml"&gt;Code for render-link.html&lt;/h3&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* /layouts/_markup/render-link.html */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Destination&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;safeURL&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$currentLang&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Page.Language.Locale&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_external&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;or&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;http://&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;https://&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_anchor&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;#&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_asset&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/assets/&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_lang_prefixed&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;printf&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/%s/&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$currentLang&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_anchor&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;printf&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;%s%s&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Page.RelPermalink&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_external&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;and&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_lang_prefixed&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_asset&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Page.Language.IsDefault&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;printf&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/%s%s&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$currentLang&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{- /* Add / to internal paths that have no file extension */ -}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;and&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_external&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_anchor&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_asset&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$parsed&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;urls&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Parse&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;and&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$parsed&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;not&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasSuffix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$parsed&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Path&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;))&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;eq&lt;/span&gt; &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;path&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Ext&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$parsed&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.Path&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;printf&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;%s/&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;a&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;relURL&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_external&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;target&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;_blank&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;rel&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;noopener noreferrer&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;external-link&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;{{&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;}}&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Text&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;safeHTML&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_external&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;&lt;span style="color:#f92672"&gt;span&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;external-link-icon&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;aria-hidden&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;&amp;gt;↗&amp;lt;/&lt;span style="color:#f92672"&gt;span&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;a&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As long as the default language and the weight index match, meaning the default language appears at the very top, this works perfectly well.&lt;/p&gt;&#10;&lt;h3 id="additional-functions"&gt;Additional functions&lt;/h3&gt;&#10;&lt;p&gt;Here is what else the code does:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Checks if links are &amp;ldquo;external&amp;rdquo; - that is, leading away from my site. These are assigned a separate window and the &lt;code&gt;↗&lt;/code&gt; indicator.&lt;/li&gt;&#10;&lt;li&gt;Checks if the link points to a heading &amp;ldquo;anchor.&amp;rdquo; See below for details.&lt;/li&gt;&#10;&lt;li&gt;Checks if the link points to a (media) file or &amp;ldquo;asset.&amp;rdquo; Asset links are not modified.&#10;If it is a heading link, it is expanded to the full URL if necessary. This follows this pattern: &lt;code&gt;#heading --&amp;gt; /&amp;lt;opt_lang&amp;gt;/&amp;lt;opt_category&amp;gt;/&amp;lt;title&amp;gt;/#heading&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="permalinks"&gt;Correctly resolving permalinks to headings&lt;/h2&gt;&#10;&lt;p&gt;I have also found a solution for highly specific permalinks within a page. Let&amp;rsquo;s assume I have headings in different languages:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# German&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# content/de/posts/yyyy-mm-dd-hugo-create-language-specific-permalinks.md&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;description&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Mit _markup interne Links richtig routen&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;title&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;Hugo Multi-Lang: Interne Permalinks&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;slug&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;hugo-create-language-specific-permalinks&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;## Richtig auflösende Permalinks auf Überschriften {#permalinks}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;Lorem Ipsum...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# English&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /content/en/posts/yyyy-mm-dd-hugo-create-language-specific-permalinks.md&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;description&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Routing internal links with _markup&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;title&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;Hugo Multi-Lang: Internal Permalinks&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;slug&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;hugo-create-language-specific-permalinks&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;## Correct Permalinks to headings {#permalinks}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;Lorem Ipsum...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then, clicking &lt;a href="https://blog.schallbert.de/en/hugo-create-language-specific-permalinks/#permalinks"&gt;this link back to the #permalinks heading&lt;/a&gt; should take the user back to the heading, regardless of the language. The key to this solution is introducing a heading definition (&lt;a href="https://www.markdownlang.com/cheatsheet/headings.html#heading-ids-extended-syntax" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Heading-ID&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;) in Markdown: in this case, &lt;code&gt;{#permalinks}&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="it-doesnt-work-quite-yet"&gt;It doesn&amp;rsquo;t work quite yet.&lt;/h3&gt;&#10;&lt;p&gt;This is because &lt;code&gt;#permalinks&lt;/code&gt; points to &lt;code&gt;&amp;lt;blog's baseurl&amp;gt;/#permalinks&lt;/code&gt;, meaning the link to the current page is missing. But we can fix that, too: I&amp;rsquo;ll extend the link renderer with a few lines of code to prepend the current page URL.&lt;/p&gt;&#10;&lt;h3 id="code-for-render-linkhtml-with-heading-support"&gt;Code for render-link.html with heading support&lt;/h3&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go-html-template" data-lang="go-html-template"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_anchor&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;strings&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;.HasPrefix&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;#&amp;#34;&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$is_anchor&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;printf&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;%s%s&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;.Page.RelPermalink&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;$url&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{{-&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;end&lt;/span&gt; &lt;span style="color:#75715e"&gt;-}}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now, &lt;code&gt;(#permalinks)&lt;/code&gt; expands to &lt;code&gt;/hugo-create-language-specific-permalinks/#permalinks&lt;/code&gt;, so the link finally leads to the correct destination.&lt;/p&gt;&#10;</description></item><item><title>SSH connection errors</title><link>https://blog.schallbert.de/en/ssh-typical-issues/</link><pubDate>Fri, 12 Jun 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/ssh-typical-issues/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-06-12-ssh-errors-thumb.avif"&#10; class="post-cover"&#10; alt="Image: A list of different SSH error messages. Some very common, but a few have rare causes."&#10; title="SSH connection errors" /&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-06-12-ssh-icon.svg" alt="Image: terminal logo image from Aegis simple image pack, framing the topic of this post."&gt;&lt;/figure&gt;&#10;This short article deals with errors encountered when establishing an &lt;code&gt;SSH&lt;/code&gt; connection. Whether connecting to other computers, servers, or services like version control systems, the error messages and their corresponding solutions are usually the same. However, it is only worth reading further if standard help pages - such as &lt;a href="https://docs.github.com/en/authentication/troubleshooting-ssh" target="_blank" rel="noopener noreferrer" class="external-link"&gt;GitHub&amp;rsquo;s SSH troubleshooting guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; - have not provided the desired solution.&lt;/p&gt;&#10;&lt;h2 id="problem-no-route-to-host"&gt;Problem: &amp;ldquo;No Route To Host&amp;rdquo;&lt;/h2&gt;&#10;&lt;p&gt;The console output shows (on a local network):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ ssh 192.168.0.27&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ssh: connect to host 192.168.0.27 port 22: No route to host&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Or (on a remote network):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ ssh 1.2.3.4&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ssh: connect to host 1.2.3.4 port 22: Network is unreachable&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The cause is very likely an incorrectly entered IP address or a failed DNS lookup. On a local network, a common reason is that the target device is powered off, preventing the router from reaching it. The issue can usually be resolved by correcting the destination address, powering on the target device, or updating the routing table.&lt;/p&gt;&#10;&lt;h2 id="problem-connection-refused"&gt;Problem: Connection Refused&lt;/h2&gt;&#10;&lt;p&gt;Terminal output:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ git push gitea&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ssh: connect to host git.schallbert.de port 22: Connection refused&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;fatal: Could not read from remote repository.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In this case, a connection to the destination cannot be established at all; the process fails before authentication even begins. In my experience, such errors arise for three reasons:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The host is not yet ready. For example, it might be booting up (the network card is active), but the SSH service has not started. The usual solution here is simply to wait.&lt;/li&gt;&#10;&lt;li&gt;A firewall, rate limiter, or services like &lt;em&gt;fail2ban&lt;/em&gt; reject the connection (&lt;code&gt;REJECT&lt;/code&gt;). This is often caused by too many failed login attempts or an overambitious bot detection mechanism. In rare cases, your IP address might also appear on a &amp;ldquo;deny-list&amp;rdquo; loaded by the target system. In this instance, too, the best course of action is to wait.&lt;/li&gt;&#10;&lt;li&gt;The service in question expects a port other than the standard SSH &lt;code&gt;Port 22&lt;/code&gt;. Consequently, the user, target address, and port do not match.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The port issue is easily resolved.&#10;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-06-12-gitea-server-configuration-ssh-port.avif" alt="Image: Gitea showing SSH port IDs under /admin/config/Server_Configuration/SSH_Configuration, Port:222, Listen Port: 22"&gt;&lt;/figure&gt;&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Determine the Port on the host (Server Configuration / SSH). The image shows the settings for my Gitea instance.&#10;Alternatively, simply checking the service&amp;rsquo;s configuration file is usually sufficient.&lt;/li&gt;&#10;&lt;li&gt;Specify the correct port number in the SSH configuration file at &lt;code&gt;~/.ssh/config&lt;/code&gt;. Example:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /.ssh/config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Host gitserver&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Hostname git.schallbert.de&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Port &lt;span style="color:#ae81ff"&gt;222&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="problem-too-many-authentication-failures"&gt;Problem: Too Many Authentication Failures&lt;/h2&gt;&#10;&lt;p&gt;Terminal output:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ git pull&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Received disconnect from &amp;lt;ipAddress&amp;gt; port &amp;lt;portId&amp;gt;: Too many authentication failures&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So, we have a connection to the server 😁, but it won&amp;rsquo;t let us in 😨. At least it tells us why: it claims we&amp;rsquo;ve tried too many keys at its front door.&lt;/p&gt;&#10;&lt;h3 id="checking-the-server-login-limit"&gt;Checking the server login limit&lt;/h3&gt;&#10;&lt;p&gt;This error occurs when the server&amp;rsquo;s limit for login attempts is exceeded. For the &lt;code&gt;sshd&lt;/code&gt; service, the default value is, for example, &lt;a href="https://www.baeldung.com/linux/too-many-authentication-failures-error" target="_blank" rel="noopener noreferrer" class="external-link"&gt;MaxAuthTries = 6&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. If you have saved many keys for multiple connections - or, like me, have recently started using &lt;a href="https://blog.schallbert.de/en/hardware-security-keys-yubikey/"&gt;Hardware Security Keys&lt;/a&gt; and set up backup keys (effectively doubling the key count) - this limit is quickly reached.&lt;/p&gt;&#10;&lt;p&gt;The tricky thing about this error is that it only appears with specific services &amp;ldquo;further down&amp;rdquo; in the agent&amp;rsquo;s keychain: after all, five keys are allowed to fail before the limit is hit. Furthermore, having the keys correctly configured in &lt;code&gt;~/.ssh/config&lt;/code&gt; doesn&amp;rsquo;t protect you; the agent always attempts to use all available keys unless explicitly told otherwise.&lt;/p&gt;&#10;&lt;p&gt;We can prevent this by using &lt;code&gt;IdentitiesOnly yes&lt;/code&gt;. This explicitly instructs the agent to use only the identities (i.e., key files or username/password) specified for that particular host.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /.ssh/config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Host gitserver&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Hostname git.schallbert.de&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Port &lt;span style="color:#ae81ff"&gt;222&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;User &amp;lt;user&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;PreferredAuthentications publickey&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;IdentityFile &amp;lt;path_to_private_key1&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Identityfile &amp;lt;path_to_private_key2&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;IdentitiesOnly yes&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="checking-the-git-config"&gt;Checking the Git config&lt;/h3&gt;&#10;&lt;p&gt;You can also encounter this error if the repository&amp;rsquo;s Git configuration (located at &lt;code&gt;&amp;lt;reponame&amp;gt;/.git/config&lt;/code&gt;) does not match the server and repository details defined in your SSH configuration (&lt;code&gt;~/.ssh/config&lt;/code&gt;). If you change the SSH configuration, all repository configurations maintained locally must also be updated.&lt;/p&gt;&#10;&lt;p&gt;Example:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# &amp;lt;reponame&amp;gt;/.git/config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;remote &lt;span style="color:#e6db74"&gt;&amp;#34;origin&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; ssh://git@git.schallbert.de:222/schallbert/&amp;lt;reponame&amp;gt;.git&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;fetch &lt;span style="color:#f92672"&gt;=&lt;/span&gt; +refs/heads/*:refs/remotes/origin/*&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;branch &lt;span style="color:#e6db74"&gt;&amp;#34;main&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;remote &lt;span style="color:#f92672"&gt;=&lt;/span&gt; origin&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;merge &lt;span style="color:#f92672"&gt;=&lt;/span&gt; refs/heads/main&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;vscode-merge-base &lt;span style="color:#f92672"&gt;=&lt;/span&gt; origin/main&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In this case, the &lt;code&gt;gitserver&lt;/code&gt; configuration is not used at all; instead, the connection is established directly. Consequently, the agent does not receive the &lt;code&gt;IdentitiesOnly yes&lt;/code&gt; flag and cycles through keys until the server rejects the connection. Solution:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;remote &lt;span style="color:#e6db74"&gt;&amp;#34;origin&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; git@gitserver:schallbert/&amp;lt;reponame&amp;gt;.git&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;fetch &lt;span style="color:#f92672"&gt;=&lt;/span&gt; +refs/heads/*:refs/remotes/origin/*&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="problem-permission-denied-publickeypassword"&gt;Problem: Permission Denied (publickey,password)&lt;/h2&gt;&#10;&lt;p&gt;Console output:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ ssh -T git@gitserver&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;git@git.schallbert.de: Permission denied &lt;span style="color:#f92672"&gt;(&lt;/span&gt;publickey,password&lt;span style="color:#f92672"&gt;)&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Unfortunately, this problem can have many different causes. GitHub&amp;rsquo;s &lt;a href="https://docs.github.com/en/authentication/troubleshooting-ssh/error-permission-denied-publickey" target="_blank" rel="noopener noreferrer" class="external-link"&gt;guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; addresses a few of them, including:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Using &lt;code&gt;sudo&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Wrong server&lt;/li&gt;&#10;&lt;li&gt;Not using the &lt;code&gt;git&lt;/code&gt; user&lt;/li&gt;&#10;&lt;li&gt;Using the wrong key (client)&lt;/li&gt;&#10;&lt;li&gt;Key not registered (server)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The output for an incorrect username looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ ssh -T mit@gitserver&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;mit@git.schallbert.de: Permission denied &lt;span style="color:#f92672"&gt;(&lt;/span&gt;publickey&lt;span style="color:#f92672"&gt;)&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Another cause can be a mismatch between the &lt;code&gt;~/.ssh/config&lt;/code&gt; configuration file and the remote address stored in the &lt;em&gt;git client&lt;/em&gt;. If they do not match, any existing configuration will not be used at all.&lt;/p&gt;&#10;&lt;p&gt;You can verify this using &lt;code&gt;git remote -v&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ git remote -v&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;origin&#9;ssh://git@git.schallbert.de:222/schallbert/blog.git &lt;span style="color:#f92672"&gt;(&lt;/span&gt;fetch&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;origin&#9;ssh://git@git.schallbert.de:222/schallbert/blog.git &lt;span style="color:#f92672"&gt;(&lt;/span&gt;push&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The correct approach, using the improved configuration file (with ports explicitly set and identity files defined and restricted to the specified ones), looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Update link to server&amp;#39;s git repository, use host alias from config file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ git remote update origin git@gitserver:schallbert/blog.git&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Now check if the update was effective&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;article&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ git remote -v&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;origin&#9;git@gitserver:schallbert/blog.git &lt;span style="color:#f92672"&gt;(&lt;/span&gt;fetch&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;origin&#9;git@gitserver:schallbert/blog.git &lt;span style="color:#f92672"&gt;(&lt;/span&gt;push&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="problem-agent-refused-operation"&gt;Problem: Agent Refused Operation&lt;/h3&gt;&#10;&lt;p&gt;Console output:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;➜ blog git:&lt;span style="color:#f92672"&gt;(&lt;/span&gt;local-setup&lt;span style="color:#f92672"&gt;)&lt;/span&gt; ✗ git push --set-upstream origin local-setup&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sign_and_send_pubkey: signing failed &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; ED25519-SK &amp;lt;path-to-private-key&amp;gt; from agent: agent refused operation&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I have only encountered this error since I started using &lt;a href="https://blog.schallbert.de/en/hardware-security-key-add-ssh/"&gt;hardware security keys for SSH&lt;/a&gt;. As I see it, there are two possible causes. Fortunately, both are easy to fix:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The agent does not recognize the key. The &lt;code&gt;ssh-add&lt;/code&gt; command resolves this. Verify using &lt;code&gt;ssh-add -l | grep &amp;quot;&amp;lt;your-key's-comment&amp;gt;&amp;quot;&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;The requested key is missing. This can happen with HSKs and HSMs if they are not plugged in, not positioned correctly on the NFC reader, or if the HSM is unavailable on the network. It can also occur if the &lt;code&gt;~/.ssh/config&lt;/code&gt; file was edited manually and the key has since been renamed or deleted.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Hardware Security Key: add SSH</title><link>https://blog.schallbert.de/en/hardware-security-key-add-ssh/</link><pubDate>Sun, 03 May 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/hardware-security-key-add-ssh/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-05-03-ssh-randomart_thumb.avif"&#10; class="post-cover"&#10; alt="Image: An SSH public key randomart image as visual fingerprint for humans to quickly see if public keys match"&#10; title="Hardware Security Key: add SSH" /&gt;&#10;&lt;h2 id="motivation"&gt;Motivation&lt;/h2&gt;&#10;&lt;p&gt;In my post about &lt;a href="https://blog.schallbert.de/en/hardware-security-keys-yubikey/"&gt;hardware security keys&lt;/a&gt;, I wrote that I want to secure as many of my connections as possible without passwords provided by my new HSK. I use &lt;em&gt;SSH&lt;/em&gt; connections for various services and purposes: I access my &lt;a href="https://en.wikipedia.org/wiki/Virtual_private_server" target="_blank" rel="noopener noreferrer" class="external-link"&gt;VPS&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, use services such as GitHub, Gitea and my own &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;Gitea instance&lt;/a&gt; and run backups on the local network via &lt;a href="https://linuxize.com/post/how-to-use-scp-command-to-securely-transfer-files/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;scp&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, which uses the SSH protocol.&lt;/p&gt;&#10;&lt;h2 id="setting-up-ssh-access-for-hardware-security-keys"&gt;Setting up SSH access for hardware security keys&lt;/h2&gt;&#10;&lt;p&gt;HSKs such as my Yubikey can store private keys for SecureShell access. This means you no longer need to store them locally on every device (although this remains configurable; see below). Furthermore, they are better protected against unauthorised access than if stored on a computer.&lt;/p&gt;&#10;&lt;p&gt;So I&amp;rsquo;m trying to switch my GitHub SSH access to HSK and am following &lt;a href="https://www.yubico.com/blog/github-now-supports-ssh-security-keys/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Yubico&amp;rsquo;s guide to passwordless authentication for GitHub&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The procedure for generating FIDO-compliant SSH keys is the same across different key manufacturers. I&amp;rsquo;m also linking to the &lt;a href="https://docs.nitrokey.com/nitrokeys/features/fido2/ssh" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Nitrokey guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for reference.&lt;/p&gt;&#10;&lt;h2 id="what-doesnt-work"&gt;What doesn&amp;rsquo;t work&lt;/h2&gt;&#10;&lt;p&gt;However, even with Yubico&amp;rsquo;s own recommendations, I am unable to achieve the desired result on my operating system and with the standard installation of &lt;code&gt;openssh&lt;/code&gt;. The command assembled from the guide is:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# ssh-keygen -t ed25519-sk -O resident -O verify-required -O application&lt;span style="color:#f92672"&gt;=&lt;/span&gt;ssh:github -O user&lt;span style="color:#f92672"&gt;=&lt;/span&gt;schallbert -C &lt;span style="color:#e6db74"&gt;&amp;#39;schallbert@github.com&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Once generated, we must store the public key (file extension &lt;code&gt;.pub&lt;/code&gt;) in the web application of the respective service under the SSH Access menu item.&lt;/p&gt;&#10;&lt;h3 id="meaning-of-the-command-and-the-most-important-options"&gt;Meaning of the command and the most important options&lt;/h3&gt;&#10;&lt;p&gt;This command&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt; tells &lt;a href="https://www.man7.org/linux/man-pages/man1/ssh-keygen.1.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ssh-keygen&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to generate a key of type &lt;code&gt;-t ed25519-sk&lt;/code&gt; for me. The suffix &lt;code&gt;-sk&lt;/code&gt; indicates that the key should be stored on a hardware security key - in my case, the Yubikey.&lt;/p&gt;&#10;&lt;p&gt;To understand the &lt;code&gt;-O resident&lt;/code&gt; flag, we need to know that the FIDO authenticator key, generated as with &lt;code&gt;-sk&lt;/code&gt;, consists of two parts: a &lt;code&gt;key handle&lt;/code&gt; part, which remains on the computer&amp;rsquo;s hard drive, and the private key itself, which &lt;a href="https://www.man7.org/linux/man-pages/man1/ssh-keygen.1.html#FIDO_AUTHENTICATOR" target="_blank" rel="noopener noreferrer" class="external-link"&gt;exists only on the HSM&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Only together can they correctly respond to authentication requests. The flag itself, however, instructs the program to write both parts of the key to the Yubikey. This makes it easier to use multiple devices with the same key: the &lt;code&gt;key handle&lt;/code&gt; part can be read from the Yubikey and stored on other end devices. On the other hand, however, simply losing the Yubikey is now enough to grant potential attackers SSH access.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;-f filename&lt;/code&gt; specifies the names for the key files. This is particularly useful when you need to manage multiple SSH connections or HSMs. I&amp;rsquo;ve created a naming convention to avoid confusion. It&amp;rsquo;s based on the syntax used to read keys from an HSM (&lt;code&gt;ssh-keygen -K&lt;/code&gt;).&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;-f &amp;lt;keyType&amp;gt;_&amp;lt;protocol&amp;gt;:&amp;lt;service&amp;gt;_&amp;lt;user&amp;gt;_&amp;lt;device&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As a concrete example, my filename here would be as follows:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;-f ed25519-sk_ssh:github_schallbert_yubikey-main&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;⚠️ Note when using Windows: The colon &lt;code&gt;:&lt;/code&gt; is not allowed as a character in filenames. It is replaced by an underscore &lt;code&gt;_&lt;/code&gt;, which must also be reflected in the &lt;code&gt;config&lt;/code&gt; file (&lt;code&gt;Ctrl+H&lt;/code&gt; -&amp;gt; replace &lt;code&gt;:&lt;/code&gt; with &lt;code&gt;_&lt;/code&gt;)&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;-C schallbert@github.com&lt;/code&gt; adds a comment to the key file itself, such as a contact address. This gives the key a label, which makes it easier to manage when dealing with multiple keys.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;-O application=ssh:github&lt;/code&gt; is simply for neat listing in the Authenticator and ensures that I cannot confuse one key with another. Otherwise, it would simply say &lt;code&gt;ssh:keygen&lt;/code&gt; and I would no longer be able to see which services I have stored keys for. Also helpful to discriminate keys when using the &lt;code&gt;-resident&lt;/code&gt; option in congunction with key extraction &lt;code&gt;ssh-keygen -K&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;-O user=schallbert&lt;/code&gt; has no practical use for me, as SSH is not interested in this flag and, contrary to my expectations, the Yubikey Authenticator does not display the username.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;-O no-touch-required&lt;/code&gt; prevents the hardware security key from having to be touched every time the key is used. Useful for very frequent use, though it compromises security due to the possibility of automation.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;-O verify-required&lt;/code&gt; specifies that the HSM&amp;rsquo;s PIN must be entered before the key can be used. Although intended as an additional security measure, this causes problems for me in my shell. Specifically, the PIN is requested once, but the &lt;code&gt;ssh-agent&lt;/code&gt; does not generate a prompt in my console every time. As soon as I try to connect via SSH in another session, I receive an error message: &lt;code&gt;agent refused operation&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# ssh -T git@github.com&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sign_and_send_pubkey: signing failed &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; ED25519-SK &lt;span style="color:#e6db74"&gt;&amp;#34;schallbert@github.com&amp;#34;&lt;/span&gt; from agent: agent refused operation&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;root@opnsense: Permission denied &lt;span style="color:#f92672"&gt;(&lt;/span&gt;publickey&lt;span style="color:#f92672"&gt;)&lt;/span&gt;.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="what-was-the-problem"&gt;What was the problem?&lt;/h2&gt;&#10;&lt;p&gt;After a long, frustrating troubleshooting session (What am I doing wrong? Does it work with the secondary key? Why does it work the first time but not afterwards? What if I regenerate the keys? What happens on other computers? What does &amp;lsquo;AI&amp;rsquo; say about this? - The latter certainly took an hour and left me completely stuck), I try a different command. That works straight away.&lt;/p&gt;&#10;&lt;h2 id="what-does-work-however"&gt;What does work, however&lt;/h2&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# ssh-keygen -t ed25519-sk -O application&lt;span style="color:#f92672"&gt;=&lt;/span&gt;ssh:github -C &lt;span style="color:#e6db74"&gt;&amp;#39;schallbert@github.com&amp;#39;&lt;/span&gt; -f ed25519-sk_ssh:github_schallbert_yubikey-main&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If I omit &lt;code&gt;verify-required&lt;/code&gt;, I can log in successfully and the SSH agent runs fine.&lt;/p&gt;&#10;&lt;h3 id="what-verify-required-requires"&gt;What &lt;code&gt;verify-required&lt;/code&gt; requires&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://superuser.com/questions/1804740/yubikey-ssh-authentication-fails-with-signing-failed-for-ecdsa-sk" target="_blank" rel="noopener noreferrer" class="external-link"&gt;This post&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; sheds some light on my issue. The terminal session I use to create the SSH key pair temporarily stores the PIN entry enforced by the &lt;code&gt;verify-required&lt;/code&gt; flag. That is why the login works initially. If I log in later or in a different window, as I did above to check &lt;code&gt;git pull&lt;/code&gt; in one of my repos, for example, the SSH agent does not ask for my PIN.&lt;/p&gt;&#10;&lt;p&gt;This is likely because the SSH agent itself cannot display prompts to the requesting programme. A submodule of OpenSSH provides a solution: &lt;a href="https://packages.fedoraproject.org/pkgs/openssh/openssh-askpass/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;openssh-askpass&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; . This can be passed to the agent, which then transmits the PIN prompt to the user via &lt;em&gt;askpass&lt;/em&gt;.&lt;/p&gt;&#10;&lt;h3 id="installing-ssh-askpass"&gt;Installing ssh-askpass&lt;/h3&gt;&#10;&lt;p&gt;My operating system did not have &lt;em&gt;ssh-askpass&lt;/em&gt; installed by default, so I&amp;rsquo;ll install it here. It uses the package manager &lt;code&gt;DNF&lt;/code&gt; (&amp;lsquo;Dandified YUM&amp;rsquo;) instead of &lt;em&gt;apt&lt;/em&gt; or other common managers. In my opinion, the naming convention isn&amp;rsquo;t 100% consistent: whilst I address the ssh programme in the terminal as &lt;em&gt;ssh-agent&lt;/em&gt;, I have to type &lt;em&gt;openssh-agent&lt;/em&gt; for the installation.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# verify-required needs openssh subpackage through Fedora&amp;#39;s package manager&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# sudo dnf install openssh-askpass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Installing:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; openssh-askpass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Complete!&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now I want to tell the agent that it can use &lt;em&gt;ssh-askpass&lt;/em&gt; to request the PIN. To do this, I need to know the path to the submodule.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# which openssh-askpass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/usr/bin/which: no openssh-askpass &lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Strange, I installed it above! Let&amp;rsquo;s have a look what the package manager has to say.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# dnf repoquery --list openssh-askpass&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/usr/libexec/openssh/ssh-askpass&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Ah, openssh hides in &lt;code&gt;libexec&lt;/code&gt;. Well, then I can link &lt;code&gt;ssh-agent&lt;/code&gt; to &lt;code&gt;ssh-askpass&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# eval &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;ssh-agent -s; SSH_ASKPASS&lt;span style="color:#f92672"&gt;=&lt;/span&gt;/usr/libexec/openssh/ssh-askpass&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Agent pid &lt;span style="color:#ae81ff"&gt;16747&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-05-03-ssh-askpass-prompt.avif" alt="Image: Testing the ssh connection to Github with ssh -T and getting prompted for HSK device pin"&gt;&lt;/figure&gt;&#10;I check the connection to GitHub and, as expected, a dialogue box appears asking for my PIN. I then need to tap the HSK, after which I receive a success message. To ensure that this all works in a new terminal and after restarting the computer, I add a reference to the key in my configuration file.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# ~/.ssh/config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Host github&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; User git&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Hostname github.com&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; PreferredAuthentications publickey&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; IdentityFile ~/.ssh/ed25519-sk_ssh:github_schallbert_yubikey-main&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To be on the safe side, restart the agent and create new terminal sessions. If any login details were cached, this will reset them. When done, validate SSH connectivity.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; schallbert@machine:~# eval &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;ssh-agent -k&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;amp;&amp;amp;&lt;/span&gt; eval &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;ssh-agent -s&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="annoying-gnome-pop-up-allow-inhibiting-shortcuts"&gt;Annoying: Gnome pop-up &amp;lsquo;Allow inhibiting shortcuts&amp;rsquo;&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-05-03-gnome-inhibit-shortcuts-prompt.avif" alt="Image: Gnome pop-up &amp;#39;Allow inhibiting shortcuts"&gt;&lt;/figure&gt;&#10;The only downside is that I now get an extra pop-up when connecting via SSH. This is triggered by the &lt;em&gt;gnome&lt;/em&gt; graphical user interface. Unfortunately, I can&amp;rsquo;t find an option to save the relevant setting permanently. As a result, it appears every time I connect with &lt;code&gt;verify-required&lt;/code&gt;. With Gnome, the problem is already &lt;a href="https://gitlab.gnome.org/GNOME/gnome-connections/-/work_items/85" target="_blank" rel="noopener noreferrer" class="external-link"&gt;known to affect other applications&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, but apparently has not yet been resolved.&lt;/p&gt;&#10;&lt;h2 id="transferring-to-windows-systems"&gt;Transferring to Windows systems&lt;/h2&gt;&#10;&lt;p&gt;I generated my keys using the &lt;code&gt;-O resident&lt;/code&gt; option. It makes sense to test the extraction of the &lt;code&gt;key-handle&lt;/code&gt; on a completely different system: &lt;em&gt;Windows&lt;/em&gt;.&lt;/p&gt;&#10;&lt;p&gt;I open the command line and type&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@windows-machine:~# ssh-keygen -K&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Enter PIN &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; authenticator:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;You may need to touch your authenticator to authorize key download.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Provider &lt;span style="color:#e6db74"&gt;&amp;#34;internal&amp;#34;&lt;/span&gt; returned failure -1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Unable to load resident keys: invalid format&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Invalid format? It&amp;rsquo;s often mentioned online that the keys sometimes need a &lt;a href="https://stackoverflow.com/questions/47859437/windows-10-openssh-key-invalid-format#68593742" target="_blank" rel="noopener noreferrer" class="external-link"&gt;line feed character&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; at the end to be recognized correctly. However, I cannot modify them without downloading them from HSK. So I manually transfer the public key and key handle to the computer and try out the &lt;code&gt;git pull&lt;/code&gt; command as a test. And look: it works!&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-05-03-windows-fido-key-ssh-pin-prompt.avif" alt="Image: FIDO ssh connection test on a Windows machine: OS prompts for PIN entry. It works!"&gt;&lt;/figure&gt;&#10;&lt;p&gt;So if you want to use the keys with different operating system families, the &lt;code&gt;-resident&lt;/code&gt; option doesn&amp;rsquo;t seem to be of any use. The command &lt;code&gt;ssh-keygen -K&lt;/code&gt; does not allow granularity: It will load all resident keys from the HSK at once.&lt;/p&gt;&#10;&lt;h2 id="vps-be-careful-when-replacing-keys"&gt;VPS: Be careful when replacing keys&lt;/h2&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-05-03-hetzner-key-add-irrelevant-for-existing-servers.avif" alt="Image: Hetzner informing me that new SSH keys will not be applied to existing resources (VPS). Important to know!"&gt;&lt;/figure&gt;&#10;Another tip from personal experience: If you&amp;rsquo;ve already stored SSH keys with a service provider that hosts live-resources itself (in my case, a server as a VPS), new SSH keys are usually not passed on to that resource via the web frontend.&lt;/p&gt;&#10;&lt;p&gt;Consequently, the “old” keys are still needed, and the new ones aren&amp;rsquo;t used, even though they&amp;rsquo;re stored in the web app!&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;The new keys must be manually added to the destination.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;I follow the &lt;a href="https://community.hetzner.com/tutorials/howto-ssh-key/de#step-4---store-ssh-key-on-server" target="_blank" rel="noopener noreferrer" class="external-link"&gt;instructions from my host Hetzner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; using the &lt;code&gt;ssh-copy-id&lt;/code&gt; command.&lt;/p&gt;&#10;&lt;h3 id="manually-store-new-keys-on-the-vps"&gt;Manually store new keys on the VPS&lt;/h3&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# ssh-copy-id -i ./ed25519-sk_ssh:server_schallbert_yubikey-main.pub server &#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here, &lt;code&gt;server&lt;/code&gt; is the SSH alias for my VPS. The connection is established using the familiar “old” key, and the “new” one is saved. But something isn&amp;rsquo;t working as expected:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key&lt;span style="color:#f92672"&gt;(&lt;/span&gt;s&lt;span style="color:#f92672"&gt;)&lt;/span&gt;, to filter out any that are already installed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/usr/bin/ssh-copy-id: WARNING: All keys were skipped because they already exist on the remote system.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;&#9;&lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; you think this is a mistake, you may want to use -f option&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="was-the-key-transferred-successfully"&gt;Was the key transferred successfully?&lt;/h3&gt;&#10;&lt;p&gt;So I log back into my server and check whether the new key actually appears in the &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; file. It doesn&amp;rsquo;t. So I run the command above again, this time with the &lt;code&gt;-f (Force)&lt;/code&gt; option enabled. And sure enough, the transfer works!&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# ssh-copy-id -f -i ./ed25519-sk_ssh:server_schallbert_yubikey-main.pub server &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Number of key&lt;span style="color:#f92672"&gt;(&lt;/span&gt;s&lt;span style="color:#f92672"&gt;)&lt;/span&gt; added: &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Now try logging into the machine, with: “ssh -i &amp;lt;identityFile&amp;gt; &lt;span style="color:#e6db74"&gt;&amp;#39;server&amp;#39;&lt;/span&gt;”&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;and check to make sure that only the key&lt;span style="color:#f92672"&gt;(&lt;/span&gt;s&lt;span style="color:#f92672"&gt;)&lt;/span&gt; you wanted were added.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To test it as recommended, log in with the new key and&amp;hellip; It works! Only now can the old keys be safely deleted.&lt;/p&gt;&#10;&lt;h3 id="how-to-avoid-ssh-copy-id"&gt;How to avoid ssh-copy-id&lt;/h3&gt;&#10;&lt;p&gt;If you want to transmit the public key without using additional tools, you can copy the contents of the public key file &lt;code&gt;&amp;lt;key-filename&amp;gt;.pub&lt;/code&gt; directly into a new line of &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;. You just need an open SSH connection to your target.&lt;/p&gt;&#10;&lt;h2 id="thoughts-on-convenience-and-usability"&gt;Thoughts on convenience and usability&lt;/h2&gt;&#10;&lt;p&gt;When I look at my SSH connections, they broadly fall into two categories:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Admin access&lt;/strong&gt; (VPS, NAS, Raspberry Pi) and signatures&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Protocol access&lt;/strong&gt; (Git etc.)&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;In my view, the former require a higher level of security. Furthermore, I don&amp;rsquo;t use them as frequently as version control systems. I therefore recommend &lt;code&gt;verify-required&lt;/code&gt; for the first category and would omit this for the second. I would take the same approach with the &lt;code&gt;no-touch-required&lt;/code&gt; option: if a commit is to be uploaded every few minutes, constantly having to touch the key is rather annoying.&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;All options are displayed when you open the manual using &lt;code&gt;man ssh-keygen&lt;/code&gt;.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>Hardware Security Keys</title><link>https://blog.schallbert.de/en/hardware-security-keys-yubikey/</link><pubDate>Sat, 25 Apr 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/hardware-security-keys-yubikey/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-04-25-yubikey_thumb.avif"&#10; class="post-cover"&#10; alt="Image: A brand-new hardware security key in its package on a table. The image has been altered; its colors are value-inverted."&#10; title="Hardware Security Keys" /&gt;&#10;&lt;h2 id="definitions-tpm-and-hsk"&gt;Definitions: TPM and HSK&lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: center"&gt;Acronym&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: center"&gt;Term&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: center"&gt;Brief description&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;TPM&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;Trusted Platform Module&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;Soldered securely. Protects a device&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;HSK&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;Hardware Security Key&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: center"&gt;Mobile. Protects accounts, certificates, private keys&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The &lt;a href="https://de.wikipedia.org/wiki/Trusted_Platform_Module" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Trusted Platform Module&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is a security chip that securely stores cryptographic keys and provides functions such as Secure Boot, BitLocker encryption and device identity. It is permanently soldered onto the motherboard of a computer, smartphone, etc.&lt;/p&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-04-25-this-is-a-yubikey.avif" alt="Image: A Hardware Security Key with USB-C connector called YubiKey5 NFC."&gt;&lt;/figure&gt;&#10;A &lt;a href="https://en.wikipedia.org/wiki/Hardware_security_module" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Hardware Security Key &lt;em&gt;HSK&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, on the other hand, is usually portable and can be connected to various devices. It can not only store keys, but often also perform cryptographic operations (key generation, signing, encryption). HSKs are designed to be highly secure and tamper-proof. They can be externally certified or audited, meaning that even highly sensitive processes such as payment transactions can be carried out.&lt;/p&gt;&#10;&lt;h2 id="what-happened-so-far"&gt;What happened so far&lt;/h2&gt;&#10;&lt;p&gt;I listened to a podcast from the Bundesamt für Sicherheit in der Informationstechnik (BSI) on the topic of &lt;a href="https://www.bsi.bund.de/SharedDocs/Audio/DE/BSI/Update_verfuegbar_Folge40_2024_02_29.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&amp;lsquo;Passkeys instead of passwords&amp;rsquo;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Following the recommendations, I bought a Hardware Security Key (HSK) so that I can use &lt;a href="https://en.wikipedia.org/wiki/WebAuthn" target="_blank" rel="noopener noreferrer" class="external-link"&gt;passkeys&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for my services in future. For a long time now, I have been unhappy with my fragmented secret management system consisting of notes, browser plugins, offline password managers such as &lt;a href="https://en.wikipedia.org/wiki/KeePass" target="_blank" rel="noopener noreferrer" class="external-link"&gt;KeePass&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, &lt;a href="https://en.wikipedia.org/wiki/Secure_Shell" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;SSH&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; credentials stored on my hard drive, backup keys and &lt;a href="https://askleo.com/how-should-i-keep-recovery-codes-secure/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;emergency codes&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; from various providers.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;d finally like to bring some order to the situation without tying myself so tightly to a commercial provider that I become dependent on them.&lt;/p&gt;&#10;&lt;h2 id="objectives"&gt;Objectives&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;No longer having to remember passwords or login details&lt;/li&gt;&#10;&lt;li&gt;Setting up a system for managing passwords that is as simple as possible, yet secure&lt;/li&gt;&#10;&lt;li&gt;Bringing order to the jumble of accounts&lt;/li&gt;&#10;&lt;li&gt;Organising my digital legacy&lt;/li&gt;&#10;&lt;li&gt;Enabling others to do the same&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Welche Erwartung ich jetzt schon einmal nehmen kann: Ganz ohne Passwörter oder Pins geht es auch mit dem HSK nicht. Ein Yubikey ist kein Passwortmanager. Er kann zwar Zertifikate, Passkeys und Secrets für OTPs halten, gibt diese Geheimnisse dann aber nicht mehr preis. Mit seiner Hilfe kann man signieren (Authentizität beweisen), Verschlüsseln und abgeleitete Schlüssel erstellen.&lt;/p&gt;&#10;&lt;p&gt;In fact, password management can be centralised and simplified using the Yubikey. Popular password managers support passkeys meaning a master password is no longer required.&lt;/p&gt;&#10;&lt;h3 id="yubikey"&gt;Yubikey&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-04-25-yubico-authenticator.avif" alt="Image: Screenshot of Yubico Authenticator&amp;#39;s menu options, showing &amp;#39;Home&amp;#39;, &amp;#39;Accounts&amp;#39;, &amp;#39;Passkeys&amp;#39;, &amp;#39;Certificates&amp;#39;, and &amp;#39;Slots&amp;#39;"&gt;&lt;/figure&gt;&#10;As an HSK, I use two &lt;a href="https://www.yubico.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Yubikey&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; key fobs. One to carry with me at all times and one as a spare, which is kept in a safe place. There are other providers of mobile Hardware Security Keys, such as &lt;a href="https://www.nitrokey.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Nitrokey&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. I wasn&amp;rsquo;t aware of them before writing this article. That&amp;rsquo;s a shame, as their product is open-source and therefore an ideal candidate for testing on my blog 😥.&lt;/p&gt;&#10;&lt;h3 id="yubico-authenticator"&gt;Yubico Authenticator&lt;/h3&gt;&#10;&lt;p&gt;The Yubikey Authenticator GUI programme is useful for setting up the device, assigning functions to the &lt;em&gt;Slot&lt;/em&gt; key (e.g. confirmation or password entry via keyboard simulation) and if you use &lt;a href="https://www.bsi.bund.de/DE/Themen/Verbraucherinnen-und-Verbraucher/Informationen-und-Empfehlungen/Cyber-Sicherheitsempfehlungen/Accountschutz/Zwei-Faktor-Authentisierung/zwei-faktor-authentisierung_node.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;2FA&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;-Einmalkennwörter z.B. als &lt;a href="https://en.wikipedia.org/wiki/Time-based_one-time_password" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;TOTP&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Furthermore, my Yubikey supports &lt;a href="https://en.wikipedia.org/wiki/Public_key_infrastructure" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;PKI&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, meaning that &lt;a href="https://letsencrypt.org/certificates/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;SSL&lt;/em&gt; certificates&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and private keys can be stored securely.&lt;/p&gt;&#10;&lt;h2 id="concept-of-the-hsk"&gt;Concept of the HSK&lt;/h2&gt;&#10;&lt;p&gt;Part of the concept is that private keys are either generated directly on the hardware key or - without being permanently stored locally - are created on an end device but stored on the Yubikey. These remain on the key and cannot be exported. It supports common asymmetric methods such as &lt;em&gt;RSA&lt;/em&gt; and &lt;em&gt;ECDH&lt;/em&gt;. For further details, see this &lt;a href="https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR02102/BSI-TR-02102.pdf?__blob=publicationFile&amp;amp;v=15" target="_blank" rel="noopener noreferrer" class="external-link"&gt;BSI publication on cryptographic methods&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Furthermore, the key will be kept offline most of the time, i.e. without a power supply. Thus it is virtually impossible to compromise. An additional PIN as a protective measure in the event of key loss is a further security factor.&lt;/p&gt;&#10;&lt;h3 id="spare-key"&gt;Spare key&lt;/h3&gt;&#10;&lt;p&gt;Nevertheless, you should make a note of which access points you protect using which method, so that the correct accounts can be locked in the event of loss. A &amp;lsquo;&lt;a href="https://support.yubico.com/s/article/How-to-register-your-spare-key" target="_blank" rel="noopener noreferrer" class="external-link"&gt;spare key&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;rsquo; should be available and set up, which is almost as time-consuming as configuring the main key.&lt;/p&gt;&#10;&lt;h3 id="nfc"&gt;NFC&lt;/h3&gt;&#10;&lt;p&gt;I&amp;rsquo;d hoped that my key&amp;rsquo;s &lt;a href="https://en.wikipedia.org/wiki/Near-field_communication" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;NFC&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; support would make logging in even quicker and easier. However, two things bother me here: I simply cannot get my laptop to recognise the Yubikey via NFC. And on my mobile, NFC recognition works at best flakily. When I hold the key up to it, the app launches and asks me to enter the PIN. If I do so, I have to remove the key and hold it up to the sensor again (&amp;lsquo;Do not move the key!&amp;rsquo;). This doesn&amp;rsquo;t work every time by a long shot and seems rather prone to glitches overall. A pity.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-04-25-nfc-device-manager.avif" alt="Image: NFC shown in device manager of my Windows laptop: Drivers installed but not detecting my Yubikey"&gt;&lt;/figure&gt;&#10;&lt;h2 id="what-i-find-myself-missing-when-using-the-yubikey"&gt;What I find myself missing when using the Yubikey&lt;/h2&gt;&#10;&lt;p&gt;Overall, I do miss a few convenience features in my day-to-day use of the Yubikey.&#10;When I click on the desired keys or accounts, nothing happens. And it seems I can&amp;rsquo;t configure anything there either.&lt;/p&gt;&#10;&lt;p&gt;When using &lt;strong&gt;passkeys&lt;/strong&gt;, I&amp;rsquo;d like to see a shortcut. Clicking on the relevant key in the authenticator should open the corresponding app or browser window with the target website. Since I&amp;rsquo;ve already unlocked the HSK with the PIN at this point, the login process can even take place fully automatically once the key is tapped.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;d like to see something similar when managing &lt;strong&gt;accounts&lt;/strong&gt;. Why is the module called &amp;lsquo;Accounts&amp;rsquo; if I can&amp;rsquo;t access them directly? Here, too, I have to open the relevant web apps etc. myself, have the password manager ready, and then copy the 2FA codes from the authenticator. There must be an easier way.&lt;/p&gt;&#10;&lt;h3 id="touch-password-pin-puk-management-key-passphrase"&gt;Touch, password, PIN, PUK, management key, passphrase&lt;/h3&gt;&#10;&lt;p&gt;Depending on the module, multi-factor authentication is usually required even when using the Yubikey. The simplest method is presence detection via a touch of the dongle. This ensures that a person is actively operating the HSK and cannot, for example, be logged in automatically. &lt;em&gt;Touch&lt;/em&gt; can be used across modules; for detailed configuration, see &lt;a href="https://blog.schallbert.de/en/hardware-security-key-add-ssh/"&gt;my article on SSH&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The &lt;em&gt;Accounts&lt;/em&gt; module can optionally be secured with a password, whilst the &lt;em&gt;Passkeys&lt;/em&gt; module is secured via &lt;em&gt;PIN&lt;/em&gt;. Some providers even require a PIN to be set up as a second factor.&lt;/p&gt;&#10;&lt;p&gt;The &lt;em&gt;Certificates&lt;/em&gt; module must be protected against unauthorised access using &lt;a href="https://docs.yubico.com/yesdk/users-manual/application-piv/pin-puk-mgmt-key.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PIN, PUK and management key&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Private keys or certificates can optionally be protected with a &lt;a href="https://www.ssh.com/academy/ssh/passphrase" target="_blank" rel="noopener noreferrer" class="external-link"&gt;passphrase&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, which locks the keys themselves (and, in my view, makes no sense for the Yubikey, as the private keys cannot be read anyway).&lt;/p&gt;&#10;&lt;h2 id="feature-set-and-cryptographic-landscape"&gt;Feature set and cryptographic landscape&lt;/h2&gt;&#10;&lt;p&gt;Since the HSK promises such a significant security boost, I naturally want to make use of as many of its capabilities as possible. I&amp;rsquo;ve been playing around with the various modules for a few weeks now and have found that you really need to get your head around them and learn a lot to be able to use them all effectively. At least, once you&amp;rsquo;ve done so, you&amp;rsquo;ll have a rough idea of what the modules do and gain a good overview of internet security, encryption methods and protocols.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;“Accounts” and “Passkeys” are easy to set up for input forms (platforms, websites, shops) and apps (email clients, password managers, messengers): Highly recommended! - schallbert&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Yubikey module&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Description&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Accounts&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Management of accounts/logins, supports TOTP&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Passkeys&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Passwordless login procedure via &lt;a href="https://bitmi.de/it-sicherheitsblog-was-ist-eigentlich-fido2/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;FIDO/WebAuthn&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (public key credential)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Certificates&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Storage and use of certificates; supports &lt;a href="https://www.openpgp.org/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;OpenPGP&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (PGP) and &lt;a href="https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR02103/BSI-TR-02103.pdf?__blob=publicationFile&amp;amp;v=2" target="_blank" rel="noopener noreferrer" class="external-link"&gt;X.509&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (PIV)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Slots&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Two programmable slots for generating static passwords or one-time passwords&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;In my opinion, things get complicated and time-consuming with public/private keys and certificates. Generation and validation procedures depend on the operating system or the programmes available on it, and instructions are sometimes insufficient or contradictory: only for nerds or professionals. - schallbert&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Let&amp;rsquo;s go through the modules.&lt;/p&gt;&#10;&lt;h3 id="two-factor-authentication-yubikey-accounts"&gt;Two-factor authentication: “Yubikey: Accounts”&lt;/h3&gt;&#10;&lt;p&gt;The Yubikey can be used as a generator for time-based one-time passwords. To do this, as with any other authenticator app, you create an account for each service and transfer a security key (secret) to the Yubikey. Once the HSK is connected, one-time codes can now be generated based on this secret.&lt;/p&gt;&#10;&lt;p&gt;As there is no way to export these secrets from the Yubikey, the process must be repeated for secondary keys. However, not every provider supports the use of multiple authenticators. So it may be sensible to keep the secret in the clipboard&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt; and reuse it when configuring the secondary key(s).&lt;/p&gt;&#10;&lt;h3 id="passkeys-yubikey-passkeys"&gt;Passkeys &amp;lsquo;Yubikey: Passkeys&amp;rsquo;&lt;/h3&gt;&#10;&lt;p&gt;The Passkey module on my Yubikey works quite well. Unfortunately, many services&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt; do not yet support passwordless login or only accept Passkeys as a second factor. With other services&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;, I cannot disable the password, so I see little security benefit, or I am issued with backup codes that are intended to serve as a second factor in an emergency&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;. At least, I can now bypass the password-TOTP process for some services and log in more quickly.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;lsquo;The biggest advantages of the technology for users are, on the one hand, that in future they will no longer have to create and manage passwords. On the other hand, passkeys are immune to the vast majority of known phishing attacks.&amp;rsquo; - &lt;a href="https://www.bsi.bund.de/DE/Themen/Verbraucherinnen-und-Verbraucher/Informationen-und-Empfehlungen/Cyber-Sicherheitsempfehlungen/Accountschutz/Passkeys/passkeys-anmelden-ohne-passwort_node.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;BSI website, 2025 October&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;You do not need the Authenticator app to configure passkeys. This is done entirely within the browser or the command line. Both must support the handling of passkeys.&lt;/p&gt;&#10;&lt;h3 id="keys--certificates-yubikey-certificates"&gt;Keys &amp;amp; Certificates &amp;lsquo;Yubikey: Certificates&amp;rsquo;&lt;/h3&gt;&#10;&lt;p&gt;To shed some light on this, we first need to understand the difference between a key pair and a certificate in the context of authentication. In both cases, the aim is to verify the client&amp;rsquo;s identity. This may be done with a server (e.g. via SSH), an institution (e.g. a Certificate Authority) or another client (e.g. via &lt;em&gt;PGP&lt;/em&gt;). This allows the destination to confirm that the client attempting to connect is indeed authorised to do so.&lt;/p&gt;&#10;&lt;h3 id="publicprivate-key-pair"&gt;Public/Private Key Pair&lt;/h3&gt;&#10;&lt;p&gt;A key pair alone (i.e. without a certificate) is typically used when the communication destination is controlled by the same party as the client. In such cases, the client can place the public key on the host to establish the connection. This method is used, when setting up SSH connections and for certain APIs, such as Google Search Console.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-04-25-gitea-ssh-keys.avif" alt="Image: Gitea account security settings: SSH public key stored in Gitea Webapp"&gt;&lt;/figure&gt;&#10;&lt;p&gt;The host can send a test message to the cient encrypted with the public key, which can only be converted back into plain text using the corresponding private key. If the client sends the correct plain text back to the host, this proves to the host that the connection partner is authentic.&lt;/p&gt;&#10;&lt;h3 id="certificates--piv"&gt;Certificates / PIV&lt;/h3&gt;&#10;&lt;p&gt;But what if the host is not under the client&amp;rsquo;s control? How can we then ensure that neither the client nor the server is impersonating someone else and has stored a forged public key? This is precisely where certificates are used.&lt;/p&gt;&#10;&lt;p&gt;Put simply, the certificate contains not only the public key but also additional information about the network participant and the desired connection. Now a trusted third party, uninvolved in the communication between client and server, comes into play. In the Private Key Infrastructure PKI, this is known as the Certificate Authority &lt;em&gt;CA&lt;/em&gt;.&lt;/p&gt;&#10;&lt;p&gt;The network participant now sends the public key, together with metadata about themselves, to the CA and submits a so-called &lt;em&gt;CSR&lt;/em&gt;, a Certificate Signing Request. The CA then verifies whether the participant&amp;rsquo;s channel actually belongs to them.&lt;/p&gt;&#10;&lt;p&gt;In the case of an email (keyword &lt;a href="https://www.gpg4win.org/doc/en/gpg4win-compendium_8.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PGP/GPG&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;), the CA can, for example, send an individual hash link - derived from the requestor&amp;rsquo;s public key - to the specified email address. If the link is clicked, this proves to the CA that the email address actually belongs to the client, and the CA can then issue the certificate, i.e. sign the CSR with its private key.&lt;/p&gt;&#10;&lt;p&gt;For domains (keyword SSL/TLS), the CA can check with the Domain Name Service provider whether the details match the server&amp;rsquo;s request.&lt;/p&gt;&#10;&lt;p&gt;Clients have the signatures of common Certificate Authorities stored (e.g. in the browser). If the certificate is tampered with on the return journey between server and client, its hash value changes and the client displays a certificate warning: &amp;lsquo;invalid certificate&amp;rsquo;.&#10;However, one must now trust the Certificate Authorities&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt; 🙃&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;The computer should be offline for this. If you want to be extra secure, carry out the pairing on a live operating system. This usually boots from a USB stick and forgets everything after shutdown.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:2"&gt;&#10;&lt;p&gt;E.g. Not at all: Mailbox; 2FA: Discord and Kraken, as of 2026 April. But perhaps I haven&amp;rsquo;t found the right settings?&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:3"&gt;&#10;&lt;p&gt;E.g. Google, GitHub and Hetzner, as of 2026 April.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:4"&gt;&#10;&lt;p&gt;These backup codes must also be stored securely. Ideally offline again, but definitely independently of the HSK.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:5"&gt;&#10;&lt;p&gt;There are alternatives to institutions such as CAs; see &lt;a href="https://de.wikipedia.org/wiki/Web_of_Trust" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Web of Trust&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. For example, identity verification can also take place peer-to-peer via uninvolved third parties.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>act_runner rootless: no start</title><link>https://blog.schallbert.de/en/act-runner-dind-failed-to-start-the-child/</link><pubDate>Sat, 28 Mar 2026</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/act-runner-dind-failed-to-start-the-child/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-03-28-act_runner-dind-failed-to-start-child-solved-thumb.avif"&#10; class="post-cover"&#10; alt="Image: Top page crop of OWASP&amp;#39;&amp;#39;s Docker Security Cheat Sheet [Source, downloaded May-26](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html) [License](https://creativecommons.org/licenses/by-sa/4.0/)"&#10; title="act_runner rootless: no start" /&gt;&#10;&lt;aside class="update-box update-box--warn" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ⚠️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Gitea Retires `act_runner`&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-09-15T00:00:00Z"&gt;&#10; 2026-09-15&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; This article refers to an Actions implementation by Gitea, the &lt;code&gt;act_runner&lt;/code&gt;. It is derived from &lt;a href="https://github.com/nektos/act" target="_blank" rel="noopener noreferrer" class="external-link"&gt;nectos/act&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Gitea now uses &lt;a href="https://blog.gitea.com/release-of-runner-1.0.0/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;its own runner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The old runner should be replaced. More info: Read my post to &lt;a href="https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/"&gt;deploy hugo with Gitea Actions, docker, and caddy&lt;/a&gt;&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h2 id="the-error"&gt;The error&lt;/h2&gt;&#10;&lt;p&gt;My &lt;a href="https://blog.schallbert.de/en/gitea-act-runner-dind/"&gt;docker-in-docker (DinD) act_runner&lt;/a&gt; crashes shortly after starting with the following error message:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-03-28-act_runner-dind-failed-to-start-child-problem.avif" alt="Image: console window with docker logs text output: `\[rootlesskit:parent\] error: failed to start the child: fork/exec /proc/self/exe: operation not permitted`"&gt;&lt;/figure&gt;&#10;&lt;h2 id="issue-ticket-on-gitea"&gt;Issue ticket on Gitea&lt;/h2&gt;&#10;&lt;p&gt;I have created a ticket for this issue in the &lt;a href="https://gitea.com/gitea/act_runner/issues/721" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea community (issue #721)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Below, I&amp;rsquo;ll go into my own findings and summarise the discussion a little.&lt;/p&gt;&#10;&lt;h2 id="problem-with-kernel-permissions"&gt;Problem with kernel permissions?&lt;/h2&gt;&#10;&lt;p&gt;In a Docker-in-Docker configuration, the &lt;code&gt;act_runner&lt;/code&gt; must run its own &lt;a href="https://docs.docker.com/engine/security/#docker-daemon-attack-surface" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Docker daemon&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Only then can the runner create its own containers for the &lt;em&gt;actions&lt;/em&gt;. For security reasons, this is not permitted by default.&lt;/p&gt;&#10;&lt;h3 id="why-containers-are-not-allowed-to-start-other-containers"&gt;Why containers are not allowed to start other containers&lt;/h3&gt;&#10;&lt;p&gt;In the context of &lt;em&gt;act_runner&lt;/em&gt;, an action executes code that is itself part of the repository. If malicious code is introduced into the job container unnoticed, e.g. by a &amp;lsquo;collaborator&amp;rsquo;, it can, in a Docker installation without DinD, &lt;a href="https://docs.docker.com/engine/security/rootless/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;under certain circumstances&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; potentially gain direct access to the host system.&lt;/p&gt;&#10;&lt;h3 id="consequences-if-something-goes-wrong-despite-dind"&gt;Consequences if something goes wrong despite DinD&lt;/h3&gt;&#10;&lt;p&gt;When using the DinD concept, an attack can result in access to the &lt;code&gt;dockerd&lt;/code&gt; process within the &lt;em&gt;act_runner&lt;/em&gt; container - and only if the action is not properly encapsulated. Still not ideal, but acceptable: when the container is restarted, the status quo ante is restored. Access to the host system is indirect, as the container itself is equipped with kernel features.&lt;/p&gt;&#10;&lt;h3 id="simple-solution-start-the-dind-container-as-privileged"&gt;Simple solution: Start the DinD container as &lt;code&gt;privileged&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;To allow the DinD container to set up its own &lt;em&gt;actions&lt;/em&gt;, &lt;code&gt;privileged: true&lt;/code&gt; can be set in the configuration. This grants the container &lt;strong&gt;all kernel capabilities&lt;/strong&gt;. According to the (excellent) &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#rule-3-limit-capabilities-grant-only-specific-capabilities-needed-by-a-container" target="_blank" rel="noopener noreferrer" class="external-link"&gt;OWASP Security Cheat Sheet&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, this should be avoided where possible. Should &lt;em&gt;act_runner&lt;/em&gt; itself now come under attack or reveal critical security vulnerabilities, the intruder would already have every opportunity to bypass the encapsulation from the host system.&lt;/p&gt;&#10;&lt;p&gt;If you want to take the easy route, the corresponding &lt;code&gt;docker-compose.yml&lt;/code&gt; file looks as follows.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# section ACT_RUNNER&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;runner&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/act_runner:latest-dind-rootless&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container_name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea-runner&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;privileged&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;And remember: Do not run containers with the &amp;ndash;privileged flag!!!&amp;rdquo; - OWASP&amp;rsquo;s Docker Security Cheat Sheet, 2026 &lt;a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;License&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;h3 id="dead-end-only-grant-the-rights-that-are-absolutely-necessary"&gt;Dead end: Only grant the rights that are absolutely necessary&lt;/h3&gt;&#10;&lt;p&gt;After a quick search, I find an &lt;a href="https://www.codestudy.net/blog/can-i-run-docker-in-docker-without-using-the-privileged-flag/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;article on CodeStudy.net&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;, which deals with the topic of DinD. From this, I put together some changes to my &lt;code&gt;docker-compose.yml&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;My approach:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Make changes to &lt;code&gt;docker-compose.yml&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Restart the service &lt;code&gt;docker compose restart &amp;lt;service&amp;gt;&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;View logs &lt;code&gt;docker logs --tail 100 &amp;lt;container-name&amp;gt;&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;If started: Run the programme &lt;code&gt;web-app-&amp;gt;repo-&amp;gt;jobs-&amp;gt;rerun_all-jobs&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;If it fails: Repeat&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;I start with few, but very powerful, permissions. After a few iterations, I get:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# section act_runner DinD-rootless&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;runner&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/act_runner:latest-dind-rootless&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container_name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea-runner&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;privileged&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;cap_add&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;SYS_ADMIN&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;security_opt&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#66d9ef"&gt;no&lt;/span&gt;-&lt;span style="color:#ae81ff"&gt;new-privileges:true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;apparmor:unconfined &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;systempaths=unconfined&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;writable-cgroups=true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But I&amp;rsquo;m still missing permissions that lie outside kernel privileges and security zones: the Docker daemon needs to be able to access &lt;code&gt;sysfs&lt;/code&gt; and &lt;code&gt;proc&lt;/code&gt;, i.e. system file directories and processes.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;level&lt;span style="color:#f92672"&gt;=&lt;/span&gt;warning msg&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;[rootlesskit:child ] failed to mount sysfs, falling back to read-only mount: operation not permitted&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I cannot resolve this even with &lt;code&gt;cap_add: ALL&lt;/code&gt;. Furthermore, online resources on this technical level are really scarce (and I am not a Docker specialist). A &lt;a href="https://zhsj.me/blog/view/dind-without-privileged" target="_blank" rel="noopener noreferrer" class="external-link"&gt;re-mount&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; of the file systems might still help here; but that seems too experimental and error-prone to me.&lt;/p&gt;&#10;&lt;p&gt;After several more hours of research and trial and error on my server, I have to agree with &lt;a href="https://github.com/docker-library/docker/issues/546" target="_blank" rel="noopener noreferrer" class="external-link"&gt;@tianon in a discussion on GitHub&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;: The Docker daemon requires so many permissions and capabilities that you might as well stick with &lt;code&gt;privileged: true&lt;/code&gt; and avoid having to grapple with an armada of &lt;code&gt;CAP_ADD&lt;/code&gt; and system bind mounts.&lt;/p&gt;&#10;&lt;h3 id="future-solution-virtualisation-or-daemonless"&gt;Future solution: Virtualisation or daemonless&lt;/h3&gt;&#10;&lt;p&gt;However, there may be better solutions: dedicated container runtime environments such as &lt;a href="https://github.com/nestybox/sysbox" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Sysbox&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The box itself has no special privileges on the host system; yet, much like in a virtual machine, it appears to be able to provide applications running within it with full access and capabilities.&lt;/p&gt;&#10;&lt;p&gt;Covering everything from installation and setup to fully functional job containers would take this post too far afield. Therefore, for the time being I must refer to other &lt;a href="https://www.jaburjak.cz/posts/docker-in-docker-unprivileged/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;sources&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Switching &lt;a href="https://tiendu.github.io/2025/04/18/dind.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;from Docker to Podman&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is also a possible solution: Podman is daemonless, rootless and offers a similar range of features to Docker. Migrating to Podman would be a project in its own right for me and does not fit within the scope of this post.&lt;/p&gt;&#10;&lt;h2 id="linux-security-modules-seccomp-apparmor-selinux"&gt;Linux Security Modules (seccomp, AppArmor, SELinux)&lt;/h2&gt;&#10;&lt;p&gt;Definition: &lt;a href="https://www.kernel.org/doc/html/latest/admin-guide/LSM/index.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;LSM&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; enable various security checks and restrictions at the kernel level. Through &amp;lsquo;Mandatory Access Control&amp;rsquo;, security extensions such as AppArmor can control kernel capabilities for individual applications and block access where necessary.&lt;/p&gt;&#10;&lt;h3 id="apparmor"&gt;AppArmor&lt;/h3&gt;&#10;&lt;p&gt;I run my server (VPS) on Ubuntu. In its more recent versions, this operating system has integrated &amp;lsquo;user namespace creation restrictions&amp;rsquo; into AppArmor, which prevents &lt;em&gt;appimages&lt;/em&gt;, &lt;em&gt;WebApps&lt;/em&gt; and &lt;em&gt;containers&lt;/em&gt; from running with elevated privileges.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Unprivileged user namespaces are a feature in the Linux kernel [&amp;hellip;]; it enables unprivileged users to gain administrator (root) permissions within a confined environment [&amp;hellip;]&amp;rdquo; - mbelair, Ubuntu Discourse, as of May-2026, &lt;a href="https://discourse.ubuntu.com/t/understanding-apparmor-user-namespace-restriction/58007" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Ubuntu Discourse Website&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;In short, this tool was developed as a &lt;a href="https://de.wikipedia.org/wiki/H%C3%A4rten_%28Computer%29" target="_blank" rel="noopener noreferrer" class="external-link"&gt;security-hardening measure&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to reduce the attack surface on the host system when running programmes that require elevated privileges.&lt;/p&gt;&#10;&lt;h3 id="simple-solution-disable-completely"&gt;Simple solution: Disable completely&lt;/h3&gt;&#10;&lt;p&gt;The sledgehammer approach completely disables the feature for user namespaces. We tell AppArmor that third-party programmes may use kernel features or elevated privileges without restrictions, just as in older operating system versions. I found the relevant command on the &lt;a href="https://askubuntu.com/questions/1511854/how-to-permanently-disable-ubuntus-new-apparmor-user-namespace-creation-restric" target="_blank" rel="noopener noreferrer" class="external-link"&gt;AskUbuntu forum&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;It disables restrictions for this session by overwriting the kernel parameters at runtime (&lt;code&gt;-w&lt;/code&gt;).&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbertTestsThis@machine:~# sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I find the command very useful for troubleshooting. If you want to find out whether the desired programme is failing to start because of AppArmor:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Enter the command above&lt;/li&gt;&#10;&lt;li&gt;Test the third-party programme, the container, etc.&lt;/li&gt;&#10;&lt;li&gt;Reboot. Or enter the command with &lt;code&gt;=1&lt;/code&gt;. This will restore the original state.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;If you wish to retain this vulnerability permanently, enter:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;makeVulnerability@machine:~# echo &lt;span style="color:#e6db74"&gt;&amp;#39;kernel.apparmor_restrict_unprivileged_userns = 0&amp;#39;&lt;/span&gt; | sudo tee /etc/sysctl.d/20-apparmor-donotrestrict.conf&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;makeVulnerability@machine:~# sudo shutdown -r now&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This in itself does not constitute a security vulnerability. It has merely become easier, in principle, to exploit any weaknesses in the kernel and escape the container&amp;rsquo;s “sandbox”.&lt;/p&gt;&#10;&lt;h3 id="the-correct-solution-tailor-settings-for-each-application"&gt;The correct solution: Tailor settings for each application&lt;/h3&gt;&#10;&lt;p&gt;There are applications (such as my DinD version of &lt;code&gt;act_runner&lt;/code&gt;) that absolutely require elevated privileges to function properly. And only these should be granted the ability to access non-admin user namespaces.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://docs.docker.com/engine/security/apparmor/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Docker itself&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; has dedicated a section in its documentation to &lt;em&gt;AppAmor&lt;/em&gt;. To arrive at the solution for the DinD runner, a &lt;a href="https://www.spad.uk/posts/rootless-dind-noble/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;bit of transfer (thanks, @thespad)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is required. To recap, here is the error message from above:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;rootlesskit:parent&lt;span style="color:#f92672"&gt;]&lt;/span&gt; error: failed to start the child: fork/exec /proc/self/exe: operation not permitted&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner | s6-svwait: fatal: some services reported permanent failure or their supervisor died&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The log file indicates that the &lt;code&gt;parent&lt;/code&gt; (Docker daemon in the container) cannot start its &lt;code&gt;child&lt;/code&gt; (runner container) because it cannot create processes (&lt;code&gt;/proc/self&lt;/code&gt;) for other participants (&lt;code&gt;fork&lt;/code&gt;). Who is the user of this daemon? &lt;code&gt;rootlesskit&lt;/code&gt;.&#10;This is exactly where our solution comes in: we need to enable the AppArmor profile for &lt;em&gt;rootlesskit&lt;/em&gt; in the act_runner&amp;rsquo;s DinD container within the &lt;code&gt;docker-compose.yml&lt;/code&gt; file.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# section act_runner DinD-rootless&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;runner&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/act_runner:latest-dind-rootless&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container_name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea-runner&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;privileged&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;security_opt&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;apparmor=rootlesskit&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The log file now looks fine. Done!&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2026-03-28-act_runner-dind-failed-to-start-child-solved.avif" alt="Image: act_runner rootless-DinD logfile with --privileged:true and --security_opt:apparmor=rootlesskit, showing a clean startup"&gt;&lt;/figure&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;I get the feeling that post was written by an &amp;lsquo;AI&amp;rsquo;. When it gets specific and talks about &amp;lsquo;critical volume mounts&amp;rsquo;, there are no actual system paths listed, and the article becomes so vague overall that I can&amp;rsquo;t actually reach my goal by following the advice.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>Jekyll-dockerimage: Bundler and Gemfile</title><link>https://blog.schallbert.de/en/bundler-ci-gemfile-issue/</link><pubDate>Fri, 14 Nov 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/bundler-ci-gemfile-issue/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-11-15-bundler-errors.avif"&#10; class="post-cover"&#10; alt="Image: bundler logo with error overlay"&#10; title="Jekyll-dockerimage: Bundler and Gemfile" /&gt;&#10;&lt;h2 id="what-is-bundler"&gt;What is &lt;em&gt;bundler&lt;/em&gt;?&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://bundler.io/guides/faq.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;Bundler&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is a tool that can be used to manage and version dependencies between modules and libraries for &lt;a href="https://www.ruby-lang.org/en/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;Ruby&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; language. I use &lt;em&gt;Bundler&lt;/em&gt; commands regularly, e.g. to build my blog and put it live on the server.&lt;/p&gt;&#10;&lt;p&gt;If I want to build locally, I type&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;bundle exec jekyll serve --incremental --future&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;into the console. This command tells &lt;em&gt;Bundler&lt;/em&gt; to run the &lt;em&gt;jekyll&lt;/em&gt; application in server mode and provides it with parameters that prevent &lt;em&gt;jekyll&lt;/em&gt; from completely rebuilding every time a file is changed and still create pages that have not yet been published.&lt;/p&gt;&#10;&lt;h2 id="bundler-in-the-ci-pipeline"&gt;&lt;em&gt;bundler&lt;/em&gt; in the CI pipeline&lt;/h2&gt;&#10;&lt;p&gt;I also use &lt;em&gt;Bundler&lt;/em&gt; as part of a Jekyll Docker image to publish my site, as I have often linked to, e.g. when moving to &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;self-hosted&lt;/a&gt;. Since switching to a new version of the blog software lately, I have been seeing puzzling errors in my CI pipeline. Locally however, the system builds flawlessly. I have documented how to address and fix such errors here.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;There was an error while trying to write to /path/to/Gemfile.lock&amp;rdquo; - CI console output&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;h3 id="no-write-permissions"&gt;No write permissions&lt;/h3&gt;&#10;&lt;p&gt;Like the &lt;em&gt;Jekyll&lt;/em&gt; user, &lt;em&gt;Bundler&lt;/em&gt; itself can only read files on my CI and cannot write them. I can fix this for the &lt;em&gt;Jekyll&lt;/em&gt; output with &lt;code&gt;chown&lt;/code&gt;, but I don&amp;rsquo;t want to allow &lt;em&gt;bundler&lt;/em&gt; to do this: I require &lt;code&gt;Gemfile.lock&lt;/code&gt; to remain identical between my local build environment and the CI so that I can fix errors in advance.&lt;/p&gt;&#10;&lt;h3 id="specification-file-for-bundler"&gt;Specification file for &lt;em&gt;bundler&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;The &lt;code&gt;Gemfile.lock&lt;/code&gt; file contains all dependencies of the application used, including version numbers and sources. If only the source code of the application plus this file is provided, &lt;em&gt;bundler&lt;/em&gt; can pull a specific version of all dependencies during build time, allowing to work with similar requirements regardless of machine and version.&lt;/p&gt;&#10;&lt;h3 id="troubleshooting"&gt;Troubleshooting&lt;/h3&gt;&#10;&lt;p&gt;A first attempt to fix the issue by aligning all versions between local and CI failed.&#10;The second attempt, to give the Jekyll user write permissions to the &lt;code&gt;Gemfile.lock&lt;/code&gt;, also failed.&#10;The third attempt led me to the Bundler website, where I took a closer look at the parameters, keyword “frozen”.&lt;/p&gt;&#10;&lt;p&gt;The fact is that you can prohibit &lt;em&gt;bundler&lt;/em&gt; from rewriting the &lt;code&gt;Gemfile.lock&lt;/code&gt;. To do this, use the command&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;bundle config set frozen true&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;According to its &lt;a href="https://bundler.io/v2.7/man/bundle-config.1.html#LIST-OF-AVAILABLE-KEYS" target="_blank" rel="noopener noreferrer" class="external-link"&gt;documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, processing is aborted as soon as the file is about to be rewritten.&lt;/p&gt;&#10;&lt;p&gt;Although I had added this command to my &lt;code&gt;yaml&lt;/code&gt; file in the CI, the build failed again. The trigger was the same library &lt;a href="https://nokogiri.org/#" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;em&gt;nokogiri&lt;/em&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; as before, but the error message was now much more helpful:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-11-14-gemfile-lock-incompatible.avif" alt="Image: jekyll build error due to build platform incompatibility between local and remote."&gt;&lt;/figure&gt;&#10;&lt;h2 id="optimize-gemfilelock-for-different-environments"&gt;Optimize &lt;code&gt;Gemfile.lock&lt;/code&gt; for different environments&lt;/h2&gt;&#10;&lt;p&gt;So I follow the suggestion and execute the desired command on my local machine:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;bundle lock --add-platform x86_64_musl &lt;span style="color:#75715e"&gt;# my CI runner&amp;#39;s environment&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Note: &lt;a href="https://musl.libc.org/about.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;musl&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is a &lt;em&gt;libc&lt;/em&gt;-implementation for Linux.&lt;/p&gt;&#10;&lt;p&gt;My lockfile now has the following entry:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;nokogiri (1.18.10-x86_64-linux-musl)&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;With this error fixed, I get better portability of my web page creation setup as a side effect.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-11-14-jekyll-yml-freeze-bundler.avif" alt="Image: successful CI run with updated dependencies"&gt;&lt;/figure&gt;&#10;</description></item><item><title>fail2ban: Configuration error</title><link>https://blog.schallbert.de/en/fail2ban-error-configuration-bad-value/</link><pubDate>Thu, 23 Oct 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/fail2ban-error-configuration-bad-value/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-09-23-docker-chain-thumb.avif"&#10; class="post-cover"&#10; alt="Image: iptables view for docker-user chain"&#10; title="fail2ban: Configuration error" /&gt;&#10;&lt;p&gt;Every few weeks, I usually check my server:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Is everything OK with Docker? &lt;code&gt;docker ps&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Are the backups being created correctly? &lt;code&gt;borgmatic repo-info&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Are my defenses working? &lt;code&gt;iptables -n -L&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Are my update automation daemons running? &lt;code&gt;htop -F /server-config&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;This time, however, &lt;code&gt;iptables&lt;/code&gt; showed a blank page on the &lt;code&gt;DOCKER-USER&lt;/code&gt; chain. This is unusual and indicates that something is wrong with &lt;em&gt;fail2ban&lt;/em&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# iptables -n -L&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Chain FORWARD &lt;span style="color:#f92672"&gt;(&lt;/span&gt;policy DROP&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;target prot opt source destination &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;DOCKER-USER all -- 0.0.0.0/0 0.0.0.0/0 &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;DOCKER-FORWARD all -- 0.0.0.0/0 0.0.0.0/0 &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Chain DOCKER-USER &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt; references&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;target port opt source destination &#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The output reads something like&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;The &lt;code&gt;FORWARD&lt;/code&gt; chain forwards, as required by &lt;em&gt;Docker&lt;/em&gt;, to the container-relevant &lt;code&gt;DOCKER-USER&lt;/code&gt; chain. However, no applications are listed under this chain that can reject or prohibit packets from being forwarded (&lt;code&gt;REJECT/DROP&lt;/code&gt;).&amp;rdquo;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Two entries for &lt;code&gt;caddy-server&lt;/code&gt; and &lt;code&gt;caddy-ratelimit&lt;/code&gt; should appear here.&lt;/p&gt;&#10;&lt;h2 id="fail2ban-error-failed-during-configuration"&gt;&amp;ldquo;fail2ban ERROR Failed during configuration&amp;rdquo;&lt;/h2&gt;&#10;&lt;p&gt;So I first check Docker:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker logs -t fail2ban &lt;span style="color:#75715e"&gt;# Print logs for the container named fail2ban with a timestamp&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Instead of being rewarded with &lt;code&gt;&amp;lt;timestamp&amp;gt; Server ready&lt;/code&gt; after scrolling through as usual, I see a never-ending flood of log messages. They repeat themselves and show that &lt;em&gt;fail2ban&lt;/em&gt; is exiting with &lt;code&gt;Exit with code 255&lt;/code&gt;. I get a better overview of the log if I only print the last 100 lines:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker logs -t --tail &lt;span style="color:#ae81ff"&gt;100&lt;/span&gt; fail2ban &lt;span style="color:#75715e"&gt;# return latest 100 lines of log with timestamp&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="the-error-message"&gt;The error message&lt;/h3&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;&amp;lt;timestamp, ID&amp;gt; ERROR Failed during configuration: Bad value substitution:&#10;option &amp;#39;action&amp;#39; in section &amp;#39;caddy-ratelimit&amp;#39; contains an interpolation key &amp;#39;banaction&amp;#39; which is not a valid option name.&#10;Raw value: &amp;#39;%(action_)s&amp;#39;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This message is interesting because I had problems with &lt;code&gt;jail.local&lt;/code&gt; at exactly this point when I was creating the article &lt;a href="https://blog.schallbert.de/en/fail2ban-with-caddy/#check-jail"&gt;Fail2ban with Caddy&lt;/a&gt;. At the time, I had to remove &lt;code&gt;action = iptables-multiport&lt;/code&gt; from the configuration because the triggering IP addresses had landed on the wrong &lt;code&gt;CHAIN&lt;/code&gt; (INPUT).&lt;/p&gt;&#10;&lt;p&gt;Now, the default action on the &lt;code&gt;DOCKER-USER&lt;/code&gt; chain seems to no longer work. So, I&amp;rsquo;m searching a few sources for a possible solution. However, I can&amp;rsquo;t find any references to updates that would cause major changes in the &lt;code&gt;action.d&lt;/code&gt; folder either in the update history of &lt;em&gt;fail2ban&lt;/em&gt; or in the &lt;a href="https://docs.linuxserver.io/images/docker-fail2ban/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;linuxserver.io&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; distribution I&amp;rsquo;m using.&lt;/p&gt;&#10;&lt;h3 id="solution-via-banaction--iptablestypemultiport"&gt;Solution via &lt;code&gt;banaction = iptables[type=multiport]&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;So I tried the web search and found a comment from a &lt;a href="https://github.com/fail2ban/fail2ban/issues/3894" target="_blank" rel="noopener noreferrer" class="external-link"&gt;fail2ban maintainer&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;[&amp;hellip;] With action = iptables[type=multiport, protocol=tcp, chain=DOCKER-USER] you overwrote the default action [&amp;hellip;] [Solution:] simply set the chain variable only in the jail [&amp;hellip;]: &lt;code&gt;port = http, https banaction = iptables[type=multiport] chain = DOCKER-USER&lt;/code&gt;&amp;rdquo; - &lt;a href="https://github.com/sebres" target="_blank" rel="noopener noreferrer" class="external-link"&gt;sebres&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, Dec-2024&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The basis for this statement can be found in the &lt;a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Docker Docs&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Side note: In all places, it is recommended to use the more modern &lt;em&gt;nftables&lt;/em&gt; instead of &lt;em&gt;iptables&lt;/em&gt; for future applications. &lt;a href="https://linux-audit.com/networking/nftables/differences-between-iptables-and-nftables-explained/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Here&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is an article on the topic.&lt;/p&gt;&#10;&lt;p&gt;This allows me to completely delete the &lt;code&gt;action&lt;/code&gt; field that was previously present in my &lt;code&gt;jail.local&lt;/code&gt; and use &lt;code&gt;banaction&lt;/code&gt; instead. It now looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# jail.local update Oct-2025&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Server/Client errors: 401 403 404 500&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#ae81ff"&gt;caddy-status]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;enabled = true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;chain = DOCKER-USER&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;port = http,https&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;filter = caddy-status&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;logpath = &amp;lt;path/to/logfiles&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;findtime = 10min&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;maxretry = 5&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;bantime = 1d&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;banaction = iptables[type=multiport]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With this configuration, &lt;em&gt;fail2ban&lt;/em&gt; boots normally and displays &lt;code&gt;Server ready&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="verifying-the-solution-using-iptables"&gt;Verifying the solution using &lt;em&gt;iptables&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;As described above, I create entries in the test log files that should trigger the actions of &lt;code&gt;caddy-status&lt;/code&gt; and &lt;code&gt;caddy-ratelimit&lt;/code&gt;. As expected, I get the following in the &amp;ldquo;blocklist&amp;rdquo;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# iptables -n -L&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Chain DOCKER-USER &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt; references&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;target prot opt source destination &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;f2b-caddy-ratelimit tcp -- 0.0.0.0/0 0.0.0.0/0 multiport dports 80,443&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;f2b-caddy-status tcp -- 0.0.0.0/0 0.0.0.0/0 multiport dports 80,443&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</description></item><item><title>passtdas - a helper for your CNC</title><link>https://blog.schallbert.de/en/passtdas/</link><pubDate>Mon, 08 Sep 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/passtdas/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-09-08-passtdas-thumb.avif"&#10; class="post-cover"&#10; alt="Image: Visualizazion of how passtdas analyzes and determines workpiece edges"&#10; title="passtdas - a helper for your CNC" /&gt;&#10;&lt;h2 id="the-problem"&gt;The Problem&lt;/h2&gt;&#10;&lt;p&gt;A friend of mine wrote to me. He uses a CNC gantry milling machine as a hobby, just like me, and often produces unique pieces. However, the resulting leftovers are sometimes too valuable to throw away. To maximize material utilization, he tries to use these offcuts as blanks for future projects.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Do you know how I can find out on the machine whether my milled part fits on the blank?&amp;rdquo; - Anonymous&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;This happens: According to the dimensions, the workpiece just fits on the blank. If you set the zero point even slightly incorrectly, the milling cutter will move beyond the workpiece boundaries, and scrap it is.&lt;/p&gt;&#10;&lt;h2 id="the-solution"&gt;The Solution&lt;/h2&gt;&#10;&lt;p&gt;It would be great if the machine followed the contour of the workpiece before starting the milling process, wouldn&amp;rsquo;t it? This way, you could easily find out whether the blank is actually completely within the workpiece boundaries.&lt;/p&gt;&#10;&lt;p&gt;Since I can&amp;rsquo;t find a quick solution online right away, I&amp;rsquo;m thinking about writing my own software to implement this. So I&amp;rsquo;m getting to work.&lt;/p&gt;&#10;&lt;p&gt;TODO: ADD VIDEO LINK&lt;/p&gt;&#10;&lt;h2 id="the-software"&gt;The Software&lt;/h2&gt;&#10;&lt;p&gt;For a quick prototype, I choose the high-level language &lt;a href="https://www.python.org/downloads/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;python&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Why? I have a fairly good command of &lt;em&gt;python&lt;/em&gt;, libraries for the necessary geometric calculations are available, and the written program can be easily compiled into an all-in-one file.&lt;/p&gt;&#10;&lt;p&gt;The program is designed to read machine instructions (G-code) and analyze the milling paths. The positions at which the maximum and minimum X and Y values are approached in the milling program are to be saved and output as a motion command in a path file.&lt;/p&gt;&#10;&lt;p&gt;If this program is loaded onto the machine, an &amp;ldquo;extreme value&amp;rdquo; determined in this way is approached and the Z-axis is lowered from the safety height to the measuring height. The program is then paused so that the operator can make adjustments to the workpiece position. Only by entering another start command will the next extreme value be reached, and so on.&lt;/p&gt;&#10;&lt;p&gt;This program will be called &lt;em&gt;passtdas&lt;/em&gt;.&lt;/p&gt;&#10;&lt;h3 id="program-type"&gt;Program type&lt;/h3&gt;&#10;&lt;p&gt;I&amp;rsquo;m choosing a console program with few parameters:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Shortcut&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Parameter name&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Description&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;-f&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;--file&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The source file to be analyzed. Required.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;-s&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;--zsafety&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Safety height the machine will do flyovers with. Optional.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;-p&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;--zprobe&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Probe height the machine will pinpoint to. Optional.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The two height parameters are given default values, so they don&amp;rsquo;t always have to be specified.&lt;/p&gt;&#10;&lt;h3 id="input"&gt;Input&lt;/h3&gt;&#10;&lt;p&gt;&lt;em&gt;passtdas&lt;/em&gt; reads files and searches them line by line for motion commands like &lt;code&gt;G00, G01, G02, G03&lt;/code&gt;. While the analysis is simple for linear movements like &lt;code&gt;G00, G01&lt;/code&gt; - linear movements can be expressed using point-to-point connections whose extreme values cannot lie between the points - it can be quite challenging for circular segments.&lt;/p&gt;&#10;&lt;p&gt;The height information on the Z-axis is also read and saved. This allows the machining technician to quickly check whether the maximum milling depth matches the desired one.&lt;/p&gt;&#10;&lt;p&gt;If the G-code file contains no instructions or incorrectly formulated instructions, &lt;em&gt;passtdas&lt;/em&gt; displays corresponding error messages and aborts the analysis.&lt;/p&gt;&#10;&lt;h3 id="definition-of-circular-segments"&gt;Definition of Circular Segments&lt;/h3&gt;&#10;&lt;p&gt;Background: Circular segments can be defined in two ways in G-code.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 X5.660 Y-0.000 Z10.000&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G02 X-5.660 Y0.000 R5.660 F1600&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Command:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Move a circular arc clockwise from point &lt;code&gt;X5.66 Y0&lt;/code&gt; with radius &lt;code&gt;5.66mm&lt;/code&gt; to point &lt;code&gt;X-5.66 Y0&lt;/code&gt; with feed rate &lt;code&gt;1600mm/min&lt;/code&gt; at height &lt;code&gt;10mm&lt;/code&gt;.&amp;rdquo;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Drawing these commands creates a hanging semicircle. Circles can be defined using three points. However, only two points are given. The third point must be calculated from the specified arc radius using the start and end points.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-09-08-arcr.avif" alt="Image: drawing an arc using given radius, start- and endpoint"&gt;&lt;/figure&gt;&#10;&lt;p&gt;&lt;em&gt;Task: Find the extreme points of this arc in the XY plane.&lt;/em&gt;&lt;/p&gt;&#10;&lt;p&gt;But an arc can also be defined like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 X-2.5696 Y5.0431&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G03 X-4.6258 Y3.2616 Z-0.5000 I2.5696 J-5.0431&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Command:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Traverse a circular arc counterclockwise from point &lt;code&gt;X-2.56 Y5.04&lt;/code&gt; with center coordinates &lt;code&gt;I2.56 J-5.04&lt;/code&gt; to point &lt;code&gt;X-4.62 Y3.26&lt;/code&gt;.&amp;rdquo;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Drawing these commands creates a short circular segment in the upper left quadrant. Although three points (start and end points, as well as the center of the circle) are given, it can sometimes be difficult to determine whether an extreme value is being reached somewhere along the arc.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-09-08-arcij.avif" alt="Image: drawing an arc using center point, start- and endpoint"&gt;&lt;/figure&gt;&#10;&lt;p&gt;&lt;em&gt;Task: Find the extreme points of this circle in the XY range.&lt;/em&gt;&lt;/p&gt;&#10;&lt;h3 id="calculating-the-extreme-points-of-circle-segments"&gt;Calculating the extreme points of circle segments&lt;/h3&gt;&#10;&lt;p&gt;A circle segment can have up to four additional extreme points, depending on its range: X+, Y+, X-, Y-. For a full circle, these are always the coordinate intersection points as seen from the radius.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;get_extremes_from_arc&lt;/span&gt;(arc, coordinates):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; x_plus_radius &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [center_x &lt;span style="color:#f92672"&gt;+&lt;/span&gt; radius, center_y, xyz[&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;]]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; y_plus_radius &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [center_x, center_y &lt;span style="color:#f92672"&gt;+&lt;/span&gt; radius, xyz[&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;]]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; x_minus_radius &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [center_x &lt;span style="color:#f92672"&gt;-&lt;/span&gt; radius, center_y, xyz[&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;]]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; y_minus_radius &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [center_x, center_y &lt;span style="color:#f92672"&gt;-&lt;/span&gt; radius, xyz[&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;]]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; extremevalue_order &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [x_plus_radius, y_plus_radius, x_minus_radius, y_minus_radius]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (arc[&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;] &lt;span style="color:#f92672"&gt;-&lt;/span&gt; arc[&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;]) &lt;span style="color:#f92672"&gt;&amp;lt;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# crossing the 0° line (x-axis), handle overflow with nested if below&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; arc[&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;] &lt;span style="color:#f92672"&gt;+=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;360&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; result &lt;span style="color:#f92672"&gt;=&lt;/span&gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; crossing_angle &lt;span style="color:#f92672"&gt;in&lt;/span&gt; range(&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;721&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;90&lt;/span&gt;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; crossing_angle &lt;span style="color:#f92672"&gt;in&lt;/span&gt; range(int(arc[&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;]), int(arc[&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;] &lt;span style="color:#f92672"&gt;+&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; i &lt;span style="color:#f92672"&gt;=&lt;/span&gt; int(crossing_angle &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;90&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; i &lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;3&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; i &lt;span style="color:#f92672"&gt;-=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;4&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; result&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(extremevalue_order[i])&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If the circle center and radius are known, the swept angle can be determined from the start and end points. This makes it clear whether the circle&amp;rsquo;s start and/or end points themselves represent extreme values, or whether additional extreme values arise from sweeping over a coordinate origin. It is precisely what the above code example does.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-09-08-arcext.avif" alt="Image: Possible coordinate extreme values of an arc segment"&gt;&lt;/figure&gt;&#10;&lt;h3 id="data-table"&gt;Data table&lt;/h3&gt;&#10;&lt;p&gt;All possible extreme values (two for a line, up to four for a circle segment) are now collected in a data table. Once each G-code line has been evaluated and the list is thus complete, the maximum and minimum values for each axis are determined from this list.&lt;/p&gt;&#10;&lt;p&gt;This ultimately creates four XYZ coordinates, which describe the maximum extent of the workpiece for each spatial direction.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-09-08-ext.avif" alt="Image: Visual example of an extreme value dataset for a simple geometry"&gt;&lt;/figure&gt;&#10;&lt;p&gt;At the same time, the program searches all commands to the Z-axis and remembers the maximum plunge depth.&lt;/p&gt;&#10;&lt;h3 id="output"&gt;Output&lt;/h3&gt;&#10;&lt;p&gt;The command-line program has no visual output. Therefore, I&amp;rsquo;ll limit myself to creating a simulation program for the milling machine in which the workpiece extension is approached and paused at each point:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G90&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Go to coordinate zero&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;MSG &lt;span style="color:#e6db74"&gt;&amp;#34;Zmin of this job: -1.0&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 Z40&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 X0 Y0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G01 Z15 F1200&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;MSG &lt;span style="color:#e6db74"&gt;&amp;#34;PathPreview: Hit START to go to Ymin: [&amp;#39;0.0&amp;#39;, &amp;#39;-5.66&amp;#39;]&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;M00&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 Z40&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 X0.0 Y-5.66&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G01 Z15 F1200&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;MSG &lt;span style="color:#e6db74"&gt;&amp;#34;PathPreview: Hit START to go to Xmin: [&amp;#39;-5.66&amp;#39;, &amp;#39;0.0&amp;#39;]&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;M00&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 Z40&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 X-5.66 Y0.0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G01 Z15 F1200&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;MSG &lt;span style="color:#e6db74"&gt;&amp;#34;PathPreview: Hit START to go to Ymax: [&amp;#39;0.0&amp;#39;, &amp;#39;5.66&amp;#39;]&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;M00&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 Z40&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 X0.0 Y5.66&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G01 Z15 F1200&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;MSG &lt;span style="color:#e6db74"&gt;&amp;#34;PathPreview: Hit START to go to Xmax: [&amp;#39;5.66&amp;#39;, &amp;#39;0.0&amp;#39;]&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;M00&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 Z40&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G00 X5.66 Y0.0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;G01 Z15 F1200&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The above example traverses the extreme values of a circle with &lt;code&gt;r=5.66mm&lt;/code&gt;. It always moves to a safety height of &lt;code&gt;Z40&lt;/code&gt; to cover the points and, upon arrival, slowly moves the Z axis at &lt;code&gt;F1200&lt;/code&gt; to the target height of &lt;code&gt;Z15&lt;/code&gt;. It is important that the zero point in &lt;code&gt;Z&lt;/code&gt; has been correctly measured beforehand - otherwise, a collision with the workpiece may occur.&lt;/p&gt;&#10;&lt;h2 id="testing-and-verification"&gt;Testing and Verification&lt;/h2&gt;&#10;&lt;p&gt;To see if the program works as expected, I created three test files:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Unit tests: Tests individual functions in the program, e.g., whether line and circle segments are correctly recognized&lt;/li&gt;&#10;&lt;li&gt;Integration tests: Tests whether functions function correctly in conjunction, e.g., the correct calculation of extreme values based on input values&lt;/li&gt;&#10;&lt;li&gt;End-to-end tests: These tests check the entire function chain, e.g., whether incorrectly formatted input files are recognized&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-09-08-unittest.avif" alt="Image: Test result summary for passtdas"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Finally, I create several test files with the program and move to the extreme values generated from them with my CNC.&lt;/p&gt;&#10;&lt;h2 id="open-source-software"&gt;Open-source software&lt;/h2&gt;&#10;&lt;p&gt;The source code for &lt;em&gt;passtdas&lt;/em&gt; is available for free on &lt;a href="https://github.com/Schallbert/passtdas" target="_blank" rel="noopener noreferrer" class="external-link"&gt;GitHub&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, but must be interpreted using Python.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;ll build a user-friendly and easy-to-use program once enough people have expressed a desire for it.&lt;/p&gt;&#10;</description></item><item><title>Rootless *act_runner* for Gitea</title><link>https://blog.schallbert.de/en/gitea-act-runner-dind/</link><pubDate>Sun, 10 Aug 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/gitea-act-runner-dind/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-08-10-gitea-act-runner-dind-rootless-thumb.avif"&#10; class="post-cover"&#10; alt="Image: Symbolized Docker containers next to text &amp;#39;&amp;#39;rootless act_runner DinD&amp;#39;&amp;#39;"&#10; title="Rootless *act_runner* for Gitea" /&gt;&#10;&lt;aside class="update-box update-box--warn" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ⚠️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Gitea Retires `act_runner`&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-09-15T00:00:00Z"&gt;&#10; 2026-09-15&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; This article refers to an Actions implementation by Gitea, the &lt;code&gt;act_runner&lt;/code&gt;. It is derived from &lt;a href="https://github.com/nektos/act" target="_blank" rel="noopener noreferrer" class="external-link"&gt;nectos/act&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Gitea now uses &lt;a href="https://blog.gitea.com/release-of-runner-1.0.0/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;its own runner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The old runner should be replaced. More info: Read my post to &lt;a href="https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/"&gt;deploy hugo with Gitea Actions, docker, and caddy&lt;/a&gt;&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;p&gt;In this article, I&amp;rsquo;ll show how to change the &lt;em&gt;act_runner&lt;/em&gt; of my &lt;em&gt;Gitea&lt;/em&gt; instance from &lt;code&gt;gitea/act_runner&lt;/code&gt; to &lt;code&gt;gitea/act_runner:latest-dind-rootless&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Why all this effort? Because &lt;code&gt;act_runner&lt;/code&gt; runs in &lt;em&gt;Docker&lt;/em&gt; and requires access to the daemon via &lt;code&gt;/var/run/docker.sock&lt;/code&gt; as a so-called volume (meaning a disk) or bind mount to function. The owner of this socket is &lt;code&gt;root&lt;/code&gt;, which gives the container virtually full access to the host system.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;RULE #1 - Do not expose the Docker daemon socket (even to the containers)&amp;rdquo; - OWASP / Docker Security Cheat Sheet&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;This is highly risky, because it&amp;rsquo;s inherent in the runner&amp;rsquo;s design to execute job code generated by third parties. It&amp;rsquo;s an open gateway. In conjunction with direct access to the host system, this could result in a total failure or hostile takeover of my infrastructure in the event of a successful attack.&lt;/p&gt;&#10;&lt;h2 id="danger-from-dockersock"&gt;Danger from &lt;code&gt;docker.sock&lt;/code&gt;&lt;/h2&gt;&#10;&lt;p&gt;In this guide, I follow the &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#docker-security-cheat-sheet" target="_blank" rel="noopener noreferrer" class="external-link"&gt;OWASP&amp;rsquo;s recommendation on Docker security&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and apply its rule(s) in practice. OWASP stands for &amp;ldquo;Open Web Application Security Project.&amp;rdquo; It is an organization dedicated to improving the security of web applications and supporting users like me with free articles, documentation, and technology.&lt;/p&gt;&#10;&lt;p&gt;The measures described under &lt;strong&gt;Rule 1&lt;/strong&gt; are:&lt;/p&gt;&#10;&lt;h3 id="leave-dockers-tcp-socket-disabled"&gt;Leave Docker&amp;rsquo;s &lt;em&gt;tcp&lt;/em&gt; socket disabled&lt;/h3&gt;&#10;&lt;p&gt;If access to the &lt;em&gt;Docker Daemon&lt;/em&gt; is enabled via &lt;em&gt;tcp&lt;/em&gt;, it can be connected to via an unsecured connection and without authentication - unless further precautions have been taken. The daemon is then accessible to virtually any internet user and thus vulnerable.&lt;/p&gt;&#10;&lt;p&gt;So how do I ensure that the &lt;em&gt;tcp&lt;/em&gt; socket is disabled?&#10;The instructions for this can be found in the &lt;a href="https://docs.docker.com/engine/daemon/remote-access/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Docker Docs&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and must be applied in reverse. There, too, there is an explicit warning against opening the &lt;em&gt;tcp&lt;/em&gt; socket unprotected.&lt;/p&gt;&#10;&lt;p&gt;After completing all the necessary steps, I run &lt;a href="https://en.wikipedia.org/wiki/Netstat" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;netstat&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; once. It lists open sockets, network interfaces, and routing tables:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# check if Docker Daemon&amp;#39;s &amp;#34;dockerd&amp;#34; tcp socket is exposed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# netstat -lntp | grep dockerd&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# test is a pass if this command does not return anything.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I don&amp;rsquo;t find any entry with &lt;code&gt;dockerd&lt;/code&gt;. My server isn&amp;rsquo;t vulnerable at this point.&lt;/p&gt;&#10;&lt;h3 id="do-not-include-the-docker-socket-varrundockersock-in-other-containers"&gt;Do not include the Docker socket &lt;code&gt;/var/run/docker.sock&lt;/code&gt; in other containers&lt;/h3&gt;&#10;&lt;p&gt;This is where things get a bit more complicated: &lt;em&gt;act_runner&lt;/em&gt; needs the socket to create, manage, and ultimately dispose of job containers. Without access to the &lt;em&gt;Docker Daemon&lt;/em&gt; via the socket, the build pipeline simply won&amp;rsquo;t work - unless you want to forgo &lt;em&gt;Docker&lt;/em&gt; entirely and run both runner and the build jobs directly on the host machine. This comes with many disadvantages: loss of encapsulation, lack of portability, poor scalability, reduced security&amp;hellip;&lt;/p&gt;&#10;&lt;p&gt;But there&amp;rsquo;s also a solution for the &lt;em&gt;Docker&lt;/em&gt; option: &lt;a href="https://gitea.com/gitea/act_runner/src/branch/main/examples/docker-compose#running-act_runner-using-docker-in-docker-dind" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Setting up act_runner Docker-in-Docker&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. With this setup, &lt;em&gt;act_runner&lt;/em&gt; receives its own &lt;em&gt;Docker Daemon&lt;/em&gt;, but with limited permissions and without access to the host system. This then takes over the lifecycle of job containers so that they run completely independently of the host system.&lt;/p&gt;&#10;&lt;h2 id="docker-in-docker"&gt;Docker in Docker&lt;/h2&gt;&#10;&lt;p&gt;The following diagram illustrates the difference:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-08-10-runner-dind-setup.avif" alt="Image: act_runner with standard configuration versus DinD-rootless. The latter has an isolated Docker Daemon running within the act_runner container."&gt;&lt;/figure&gt;&#10;&lt;h3 id="starting-act_runner-rootless"&gt;Starting Act_runner &amp;ldquo;rootless&amp;rdquo;&lt;/h3&gt;&#10;&lt;p&gt;So I follow the instructions and copy together a suitable &lt;code&gt;docker-compose.yml&lt;/code&gt;. Important here:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;code&gt;privileged: true&lt;/code&gt; must be set. Otherwise, the Docker daemon in the &lt;em&gt;act_runner&lt;/em&gt; container cannot start properly because it lacks kernel functions. This causes the entire container to crash repeatedly without generating any helpful error messages.&lt;/li&gt;&#10;&lt;li&gt;The environment variable &lt;code&gt;DOCKER_HOST=unix:///var/run/user/1000/docker.sock&lt;/code&gt; must be set. Here, the Docker socket is controlled by a non-privileged user and is available to the runner for managing job containers.&#10;The daemon runs encapsulated in the container and is not connected to the host machine.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="as-of-jul-2025-problem-with-0212-dind-rootless"&gt;As of Jul-2025: Problem with 0.2.12-dind-rootless&lt;/h3&gt;&#10;&lt;p&gt;This is where I encountered my first problem. The runner crashes shortly after starting with the following error message:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# docker logs gitea-runner&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;rootlesskit:parent&lt;span style="color:#f92672"&gt;]&lt;/span&gt; error: failed to start the child: fork/exec /proc/self/exe: operation not permitted&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Help came from the &lt;a href="https://gitea.com/gitea/act_runner/issues/721" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea community&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. &lt;em&gt;act_runner&lt;/em&gt; runs smoothly with the previous version &lt;strong&gt;0.2.11&lt;/strong&gt;. However, for reasons unknown to me, it is displayed on Gitea as 0.2.12.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-08-10-gitea-act-runner-dind-rootless.avif" alt="Image: DinD-rootless runner is now working fine"&gt;&lt;/figure&gt;&#10;&lt;h3 id="volume-confusion"&gt;Volume Confusion&lt;/h3&gt;&#10;&lt;p&gt;Great! Now that the runner is working, I&amp;rsquo;ll let it run a job right away. Unfortunately, the build fails after just a fraction of a second with this message:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;# Runner step: Set up job&#10;failed to start container: Error response from daemon: error while creating mount source path &amp;#39;&amp;lt;volumeSourceFullPath&amp;gt;&amp;#39;: mkdir &amp;lt;volumeSourcePath&amp;gt;: permission denied&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I had to research this for hours and was under the false impression for a long time that it was due to insufficient permissions on the folders on the host machine. Only later did I truly understand that Docker-in-Docker means exactly what it says: Not only are containers created by containers, but a separate Docker daemon runs within the container!&lt;/p&gt;&#10;&lt;p&gt;This means that the classic method of making volumes in job containers available directly from the host system using &lt;code&gt;-v /a/b:/x/y&lt;/code&gt; no longer works.&#10;Instead, volumes must now be passed through. Example:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&lt;em&gt;host&lt;/em&gt; directory &lt;code&gt;/opt/server/www/blog-artifacts&lt;/code&gt; -&amp;gt; &lt;em&gt;act_runner&lt;/em&gt; volume &lt;code&gt;/tmp/blog-artifacts&lt;/code&gt; &amp;ndash;&amp;gt; &lt;em&gt;job&lt;/em&gt; container volume &lt;code&gt;/tmp/blog-artifacts&lt;/code&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The &lt;code&gt;:z&lt;/code&gt; in the &lt;em&gt;act_runner&lt;/em&gt; volume is now important. It indicates to &lt;em&gt;Docker&lt;/em&gt; that this volume is shared between containers. These volumes must not only be specified in &lt;em&gt;act_runner&lt;/em&gt;&amp;rsquo;s &lt;code&gt;docker-compose.yml&lt;/code&gt;, but the job scripts in the &lt;code&gt;.gitea/workflows/&lt;/code&gt; folder must also be adjusted accordingly. However, the &lt;code&gt;:z&lt;/code&gt; on the &amp;ldquo;right side&amp;rdquo; is not needed here.&lt;/p&gt;&#10;&lt;h3 id="is-not-a-valid-volume"&gt;&amp;ldquo;is not a valid volume&amp;rdquo;&lt;/h3&gt;&#10;&lt;p&gt;Despite all my efforts, my jobs still aren&amp;rsquo;t running. This time, because of an error message that &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/#action-volumes"&gt;already seemed familiar&lt;/a&gt;:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log*" data-lang="log*"&gt;# Runner step: Set up job&#10;[/tmp/blog-artifacts] is not a valid volume, will be ignored&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So, go into the &lt;code&gt;config.yml&lt;/code&gt; of &lt;em&gt;act_runner&lt;/em&gt; and add the volume names:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /gitea/runner/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;valid_volumes&lt;/span&gt;: [&lt;span style="color:#e6db74"&gt;&amp;#34;/tmp/blog-artifacts&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;/tmp/lectures-artifacts&amp;#34;&lt;/span&gt;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In this file, I can leave &lt;code&gt;privileged: false&lt;/code&gt; because, unlike &lt;em&gt;act_runner&lt;/em&gt;, the job container doesn&amp;rsquo;t require kernel features.&lt;/p&gt;&#10;&lt;h2 id="set-permissions-correctly"&gt;Set permissions correctly&lt;/h2&gt;&#10;&lt;p&gt;Now I&amp;rsquo;m getting &lt;code&gt;Permission Denied&lt;/code&gt; error messages again when running my jobs, although not directly in the first step of the actions. Since I&amp;rsquo;ve now checked the volume paths down to the last detail, it can only be due to the folder permissions on the host machine.&lt;/p&gt;&#10;&lt;p&gt;In order for the artifacts created by the job container to be stored via the volumes on my host, I have to pass the directory to be written and all subfolders &lt;code&gt;-R&lt;/code&gt; to the previously defined, non-privileged user &lt;code&gt;ID=1000&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# chown -R 1000:1000 /target/path/to/artifact/&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Finally, everything is working smoothly, and I&amp;rsquo;ve put a stop to the (unlikely, but possible) takeover of my host system by malicious job containers.&lt;/p&gt;&#10;</description></item><item><title>Add rate limiter to Gitea</title><link>https://blog.schallbert.de/en/gitea-rate-limiter/</link><pubDate>Fri, 25 Jul 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/gitea-rate-limiter/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-07-25-gitea-rate-limiter-thumb.avif"&#10; class="post-cover"&#10; alt="Image: Gitea&amp;#39;&amp;#39;s logo, a cup of tea, protected by many arrows symbolizing GET requests"&#10; title="Add rate limiter to Gitea" /&gt;&#10;&lt;p&gt;Here, I&amp;rsquo;m providing a step-by-step guide to solving my &lt;a href="https://blog.schallbert.de/en/gitea-out-of-memory/"&gt;&lt;em&gt;Gitea&lt;/em&gt; crashes&lt;/a&gt; problem. I&amp;rsquo;m incorporating the experience I gained from using a rate limiter &lt;a href="https://blog.schallbert.de/en/fail2ban-with-caddy/"&gt;for my blog&lt;/a&gt; with the goal of making the application more robust and protected against undirected denial of service attacks.&lt;/p&gt;&#10;&lt;h2 id="step-1-specify-the-log-source-for-the-rate-limiter"&gt;Step 1: Specify the log source for the rate limiter&lt;/h2&gt;&#10;&lt;p&gt;&lt;em&gt;Gitea&lt;/em&gt; naturally generates logs itself, theoretically down to the access level by external clients. However, in the past, I haven&amp;rsquo;t been able to &lt;a href="https://blog.schallbert.de/en/server-protection/#what-does-not-yet-work-gitea--fail2ban"&gt;export Gitea&amp;rsquo;s access logs from Docker&lt;/a&gt;. Therefore, we now instruct &lt;em&gt;Caddyserver&lt;/em&gt;, which is connected as a reverse proxy, to create logs on behalf of &lt;em&gt;Gitea&lt;/em&gt;. Access to the Gitea web interface will then appear in these logs.&lt;/p&gt;&#10;&lt;p&gt;In the Caddyfile, the log module looks completely unspectacular.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ruby" data-lang="ruby"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /caddy/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;log {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;output file &lt;span style="color:#e6db74"&gt;/log/&lt;/span&gt;gitea&lt;span style="color:#f92672"&gt;/&lt;/span&gt;access&lt;span style="color:#f92672"&gt;.&lt;/span&gt;log&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="exclude-internal-traffic-from-the-log"&gt;Exclude internal traffic from the log&lt;/h3&gt;&#10;&lt;p&gt;Now I look at the logs and see entries that I don&amp;rsquo;t want. For example, &lt;em&gt;act_runner&lt;/em&gt; generates a &lt;code&gt;Fetch Task POST&lt;/code&gt; to &lt;em&gt;Gitea&lt;/em&gt; every two seconds and a &lt;code&gt;GET&lt;/code&gt; request every ten seconds with the same target as a &lt;a href="https://blog.schallbert.de/en/fix-gitea-runner/"&gt;Health check&lt;/a&gt;. These don&amp;rsquo;t even need to appear in the log for me. My first thought here was to simply not log requests from internal IP addresses. However, since &lt;em&gt;Caddy&lt;/em&gt; acts as a reverse proxy, all IP addresses run &amp;ldquo;internally&amp;rdquo; without exception.&lt;/p&gt;&#10;&lt;p&gt;So I have to recognize &lt;em&gt;act_runner&lt;/em&gt;-specific logs differently:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ruby" data-lang="ruby"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /caddy/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;git&lt;span style="color:#f92672"&gt;.&lt;/span&gt;schallbert&lt;span style="color:#f92672"&gt;.&lt;/span&gt;de {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; reverse_proxy &lt;span style="color:#f92672"&gt;*&lt;/span&gt; &lt;span style="color:#e6db74"&gt;http&lt;/span&gt;:&lt;span style="color:#e6db74"&gt;//&lt;/span&gt;&lt;span style="color:#e6db74"&gt;gitea&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;3000&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Enable logging for fail2ban, don&amp;#39;t log for runner&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; log_skip &lt;span style="color:#e6db74"&gt;/api/&lt;/span&gt;actions&lt;span style="color:#f92672"&gt;*&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; log {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; output file &lt;span style="color:#e6db74"&gt;/log/&lt;/span&gt;gitea&lt;span style="color:#f92672"&gt;/&lt;/span&gt;access&lt;span style="color:#f92672"&gt;.&lt;/span&gt;log&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;a href="https://caddyserver.com/docs/caddyfile/directives/log_skip" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;log_skip&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; directive now instructs &lt;em&gt;caddy&lt;/em&gt; to no longer create logs for access to &lt;code&gt;/api/actions*&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="reverse-proxy-displaying-remote-ips"&gt;Reverse Proxy: Displaying Remote IPs&lt;/h3&gt;&#10;&lt;p&gt;However, I still have a problem: If all requests in the log come from an internal IP address, how am I supposed to block &amp;ldquo;bad&amp;rdquo; requests? A web search &lt;a href="https://caddy.community/t/how-to-get-a-true-remote-ip-behind-caddy-reverse-proxy/22348/2" target="_blank" rel="noopener noreferrer" class="external-link"&gt;shows that this is a common problem for reverse proxies in Docker&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Unfortunately, many solutions cannot be applied to this situation because they use different server software like &lt;em&gt;nginx&lt;/em&gt; or have different services running behind their proxies than &lt;em&gt;Gitea.&lt;/em&gt;&lt;/p&gt;&#10;&lt;p&gt;But it&amp;rsquo;s actually quite simple: Just insert a line in the correct place in the &lt;code&gt;Caddyfile&lt;/code&gt;, and the remote IP addresses come in unchanged.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ruby" data-lang="ruby"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /caddy/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;git&lt;span style="color:#f92672"&gt;.&lt;/span&gt;schallbert&lt;span style="color:#f92672"&gt;.&lt;/span&gt;de {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; reverse_proxy &lt;span style="color:#f92672"&gt;*&lt;/span&gt; &lt;span style="color:#e6db74"&gt;http&lt;/span&gt;:&lt;span style="color:#e6db74"&gt;//&lt;/span&gt;&lt;span style="color:#e6db74"&gt;gitea&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;3000&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; trusted_proxies &lt;span style="color:#ae81ff"&gt;172&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;16&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt; &lt;span style="color:#75715e"&gt;# Docker-internal netwock traffic runs with these IPs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#[...] &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;a href="https://caddyserver.com/docs/caddyfile/options#trusted-proxies" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;trusted_proxies&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; directive tells &lt;em&gt;Caddy&lt;/em&gt; that the network address range provided by &lt;em&gt;Docker&lt;/em&gt; can be trusted. Now the actual source IP address is shown instead of the container&amp;rsquo;s internal interface address. This is exactly what I need to be able to analyze the addresses later with &lt;em&gt;fail2ban&lt;/em&gt;.&lt;/p&gt;&#10;&lt;h2 id="step-2-determine-the-use-case"&gt;Step 2: Determine the Use Case&lt;/h2&gt;&#10;&lt;p&gt;Let&amp;rsquo;s look at how many HTTP &lt;code&gt;200 ok&lt;/code&gt; requests are coming in the edge case between normal use and &amp;ldquo;abuse.&amp;rdquo; To do this, I surf around &lt;em&gt;Gitea&lt;/em&gt; and click on a bunch of things that I would never normally do at that speed as a human. Then I analyze the logs.&lt;/p&gt;&#10;&lt;p&gt;This gives us some initial benchmarks for the rate limiter.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# rate limiter tests&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;findtime = 10s&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;maxretry = 10&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;bantime = 6h&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="step-3-configure-fail2ban"&gt;Step 3: Configure Fail2ban&lt;/h2&gt;&#10;&lt;p&gt;Next, we configure the filter and jail file of &lt;code&gt;fail2ban&lt;/code&gt; to analyze the logs defined above.&lt;/p&gt;&#10;&lt;h3 id="filter"&gt;Filter&lt;/h3&gt;&#10;&lt;p&gt;Here, I&amp;rsquo;m re-using the same filter for my rate limiter from the &lt;a href="https://blog.schallbert.de/en/fail2ban-with-caddy/#directory-filterd"&gt;previous article&lt;/a&gt;. We&amp;rsquo;re only interested in successful requests that we count within a time window.&lt;/p&gt;&#10;&lt;h3 id="jail"&gt;Jail&lt;/h3&gt;&#10;&lt;p&gt;I&amp;rsquo;m using the values from the rate limiter tests 1:1 in the jail file. I&amp;rsquo;m referring to &lt;code&gt;caddy-ratelimit&lt;/code&gt; (link above) as the filter. It is essential to select the &lt;code&gt;DOCKER-USER&lt;/code&gt; chain as the requests are routed via Docker&amp;rsquo;s virtual network.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /fail2ban/config/fail2ban/jail.local&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#ae81ff"&gt;gitea-ratelimit]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;enabled = true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;chain = DOCKER-USER&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;port = http,https&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;filter = caddy-ratelimit&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;logpath = /var/log/caddy2/gitea/access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;findtime = 10s&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;maxretry = 10&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;bantime = 6h&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I reboot &lt;em&gt;fail2ban&lt;/em&gt; to activate the protection.&lt;/p&gt;&#10;&lt;h2 id="step-4-test-the-rate-limiter"&gt;Step 4: Test the rate limiter&lt;/h2&gt;&#10;&lt;p&gt;I proceed exactly as in my article &lt;a href="https://blog.schallbert.de/en/fail2ban-with-caddy/#test-rate-limiter"&gt;fail2ban with caddy&lt;/a&gt; and get the same result. It works!&lt;/p&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Fix &amp;#39;Bad value substitution&amp;#39; error&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2025-10-09T00:00:00Z"&gt;&#10; 2025-10-09&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; There is an important update that fixes fail2ban&amp;rsquo;s &lt;a href="https://blog.schallbert.de/en/fail2ban-error-configuration-bad-value/"&gt;Error: &amp;lsquo;Bad value substitution&amp;rsquo; for &amp;lsquo;action&amp;rsquo;&lt;/a&gt;. This problem emerges when &lt;em&gt;fail2ban&lt;/em&gt; is trying services that run in a Docker container environment.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;</description></item><item><title>Rate limiter with Caddy and fail2ban</title><link>https://blog.schallbert.de/en/fail2ban-with-caddy/</link><pubDate>Thu, 10 Jul 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/fail2ban-with-caddy/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-07-10-fail2ban-ratelimit-thumb.avif"&#10; class="post-cover"&#10; alt="Image: block drawing of how caddy interacts with fail2ban to rate limit accesses to my blog"&#10; title="Rate limiter with Caddy and fail2ban" /&gt;&#10;&lt;p&gt;Here I describe how to configure &lt;em&gt;fail2ban&lt;/em&gt; (commonly used to &lt;a href="https://blog.schallbert.de/en/server-protection/"&gt;defend against unauthorized access attempts&lt;/a&gt;) to limit the number of successful accesses within a defined time window. This is called &amp;ldquo;rate limiting&amp;rdquo; and is intended to thwart denial of service attacks that overload my server.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-07-10-fail2ban-ratelimit.avif" alt="Image: block drawing of how caddy interacts with fail2ban to rate limit accesses to my blog"&gt;&lt;/figure&gt;&#10;&lt;h2 id="enabling-caddy-logs"&gt;Enabling Caddy Logs&lt;/h2&gt;&#10;&lt;p&gt;First, we need to enable the web server&amp;rsquo;s logger. With Caddy, this can be done with just a few lines of code that I add to my &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/#caddy"&gt;existing configuration&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# caddy2/config/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;blog.schallbert.de {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Define webserver&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;root * /www/blog&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;encode gzip&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;file_server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Enable logging for fail2ban&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;log {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;output file /log/blog/access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;From now on, logs will be created in the specified path, which I manage with a &lt;a href="https://blog.schallbert.de/en/logrotate-mistake/"&gt;&lt;em&gt;logrotate&lt;/em&gt; configuration&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="configuring-fail2ban-as-a-rate-limiter"&gt;Configuring Fail2ban as a Rate Limiter&lt;/h2&gt;&#10;&lt;p&gt;To recap: &lt;em&gt;fail2ban&lt;/em&gt; accesses the machine&amp;rsquo;s packet filter rules and, in effect, modifies its firewall to ward off dynamic attacks. For this to work, &lt;em&gt;fail2ban&lt;/em&gt; must be provided with log files generated by a host application, such as a web server, that contain the IP addresses of the client computers.&lt;/p&gt;&#10;&lt;h3 id="preliminary-consideration-http-200-ok-as-a-filter"&gt;Preliminary consideration: &amp;ldquo;http 200 OK&amp;rdquo; as a filter?&lt;/h3&gt;&#10;&lt;p&gt;Even normal, permitted accesses &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/200" target="_blank" rel="noopener noreferrer" class="external-link"&gt;200 OK&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; must count for my rate limiter.&lt;/p&gt;&#10;&lt;p&gt;To get a feel for how my logger records accesses, I take a look at the logs. I get the number of entries that contain a status of &lt;code&gt;200 OK&lt;/code&gt; returned. To do this, I perform a search with &lt;em&gt;grep&lt;/em&gt; and calculate the number of hits line by line using &lt;code&gt;wc -l&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:/var/log/caddy2/blog# grep -o &lt;span style="color:#e6db74"&gt;&amp;#39;&amp;#34;status&amp;#34;:200&amp;#39;&lt;/span&gt; access.log | wc -l&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;847&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Okay, so there have already been 847 hits today, which received a &lt;code&gt;200 OK&lt;/code&gt; response. Now I&amp;rsquo;ll call up my blog&amp;rsquo;s landing page and display the article overview.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:/var/log/caddy2/blog# grep -o &lt;span style="color:#e6db74"&gt;&amp;#39;&amp;#34;status&amp;#34;:200&amp;#39;&lt;/span&gt; access.log | wc -l&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;858&lt;/span&gt; // after calling blog.schallbert.de&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:/var/log/caddy2/blog# grep -o &lt;span style="color:#e6db74"&gt;&amp;#39;&amp;#34;status&amp;#34;:200&amp;#39;&lt;/span&gt; access.log | wc -l&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;882&lt;/span&gt; // page loaded after clicking &lt;span style="color:#e6db74"&gt;&amp;#34;Posts&amp;#34;&lt;/span&gt; button&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Wow, that was 35 entries for two clicks! Looking at the log, it explains the many lines of assets—images and logos—that are being loaded.&lt;/p&gt;&#10;&lt;p&gt;Unfortunately, this makes it clear that the filter criterion &lt;code&gt;status:200&lt;/code&gt; for my rate limiter can&amp;rsquo;t work without further ado. The number of notifications depends largely on the respective article. So, I would have to define a threshold above which no normal person would generate access to my blog.&lt;/p&gt;&#10;&lt;p&gt;In order to count only &amp;ldquo;real&amp;rdquo; accesses to my pages, I have to somehow exclude the assets from the logs. Fortunately, there&amp;rsquo;s a simple &lt;a href="https://caddyserver.com/docs/caddyfile/directives/log_skip" target="_blank" rel="noopener noreferrer" class="external-link"&gt;directive in Caddy&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for this.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ruby" data-lang="ruby"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# caddy2/config/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Enable logging for fail2ban&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; log_skip &lt;span style="color:#e6db74"&gt;/assets*&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; log {&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; output file /&lt;/span&gt;log&lt;span style="color:#f92672"&gt;/&lt;/span&gt;blog&lt;span style="color:#f92672"&gt;/&lt;/span&gt;access&lt;span style="color:#f92672"&gt;.&lt;/span&gt;log&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;log_skip&lt;/code&gt; now ensures that all accesses to files in the &lt;code&gt;assets/&lt;/code&gt; folder and below are not logged.&lt;/p&gt;&#10;&lt;p&gt;The behavior of Fail2ban is defined using two configuration files:&lt;/p&gt;&#10;&lt;h3 id="directory-filterd"&gt;Directory &lt;code&gt;filter.d&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;&lt;code&gt;filter.d/&amp;lt;filter-name&amp;gt;.conf&lt;/code&gt; contains the definition of an event to be monitored. Thus, the &lt;code&gt;failregex&lt;/code&gt; &amp;ldquo;page not found&amp;rdquo; &lt;code&gt;http 404&lt;/code&gt; status code can be used to trigger the packet filter, just as successful access can be used to set up my rate limiter, which responds to &lt;code&gt;200 OK&lt;/code&gt;. I copied the regex from &lt;a href="https://www.kassner.com.br/en/2023/09/10/fail2ban-caddy-json-logs/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Rafael Kassner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /opt/fail2ban/config/filter.d/caddy-ratelimit.conf&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#ae81ff"&gt;Definition]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;failregex = &amp;#34;client_ip&amp;#34;:&amp;#34;&amp;lt;HOST&amp;gt;&amp;#34;(.*)&amp;#34;status&amp;#34;:200&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;datepattern = \d+&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;ignoreregex =&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In addition, further boundary conditions are defined in the file, e.g., the date format &lt;code&gt;datepattern&lt;/code&gt; is adapted to the log output of the system to be protected.&lt;/p&gt;&#10;&lt;h3 id="jaillocal-file"&gt;&lt;code&gt;jail.local&lt;/code&gt; file&lt;/h3&gt;&#10;&lt;p&gt;&lt;code&gt;jail.local&lt;/code&gt; determines the conditions under which the packet filter for the client IP becomes active and blocks further access attempts. For use as a rate limiter, I need the following fields:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;findtime&lt;/code&gt; - the time window in which attacks are counted, &lt;code&gt;maxretry&lt;/code&gt; is the number of permissible attempts in the time window, and &lt;code&gt;bantime&lt;/code&gt; is the time for which the attack is blocked. &lt;code&gt;ignoreip&lt;/code&gt; is usually preconfigured to the relevant internal IP addresses by default. In my case, &lt;em&gt;act_runner&lt;/em&gt;, for example, sends a message to &lt;em&gt;gitea&lt;/em&gt; internally to query whether any new automation tasks are pending. I definitely don&amp;rsquo;t want to interfere with these accesses.&lt;/p&gt;&#10;&lt;p&gt;The example for &lt;em&gt;fail2ban&lt;/em&gt; used below shows the rate limiter on my blog. In the end, the files and log entries for &lt;em&gt;gitea&lt;/em&gt; are essentially the same. Only the filter names and parameters differ due to the different requirements of the website and the DevOps platform.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /opt/fail2ban/jail.local&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#ae81ff"&gt;caddy-ratelimit]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;enabled = true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;chain = DOCKER-USER&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;action = iptables-multiport&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;port = http,https&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;filter = caddy-ratelimit&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;logpath = /var/log/caddy2/blog/access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;findtime = 20s&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;maxretry = 10&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;bantime = 6h&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Once these two files are configured, you can restart &lt;em&gt;fail2ban&lt;/em&gt; and view the logs. If there are still errors in the files, &lt;em&gt;fail2ban&lt;/em&gt; will output the following:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ERROR Errors in jail &lt;span style="color:#e6db74"&gt;&amp;#39;caddy-ratelimit&amp;#39;&lt;/span&gt;. Skipping...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If this occurs, for example, the jail cannot be assigned to the filter (must have the same name!), a field definition such as &lt;code&gt;maxretry&lt;/code&gt; is incorrectly typed, or a number format cannot be read. If everything is working correctly, the entry will be:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Reading config files: /etc/fail2ban/filter.d/caddy-ratelimit.conf&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="test-rate-limiter"&gt;Testing the Rate Limiter&lt;/h2&gt;&#10;&lt;p&gt;Now I want to check if everything is working as expected.&lt;/p&gt;&#10;&lt;h3 id="checking-the-filter-regex"&gt;Checking the Filter Regex&lt;/h3&gt;&#10;&lt;p&gt;The first step is to look at the filter term and check whether it can be reliably found in the logs. Conveniently, &lt;em&gt;fail2ban&lt;/em&gt; has a built-in tool for this: &lt;code&gt;fail2ban-regex &amp;lt;logfile&amp;gt; &amp;lt;filter&amp;gt;&lt;/code&gt;. So I can simply enter the filter file and a test log file and see if I get any matches.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;fail2ban-regex ./access.log /etc/fail2ban/filter.d/caddy-ratelimit.conf &#10;&#10;Running tests&#10;=============&#10;&#10;Use filter file : caddy-ratelimit, basedir: /etc/fail2ban&#10;Use datepattern : \d+ : \d+&#10;Use log file : ./access.log.2&#10;Use encoding : UTF-8&#10;&#10;Results&#10;=======&#10;&#10;Failregex: 2009 total&#10;|- #) [# of hits] regular expression&#10;| 1) [2009] &amp;#34;client_ip&amp;#34;:&amp;#34;&amp;lt;HOST&amp;gt;&amp;#34;(.*)&amp;#34;status&amp;#34;:200&#10;`-&#10;&#10;Ignoreregex: 0 total&#10;&#10;Date template hits:&#10;|- [# of hits] date format&#10;| [2685] \d+&#10;`-&#10;&#10;Lines: 2685 lines, 0 ignored, 2009 matched, 676 missed&#10;[processed in 0.12 sec]&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Quantitatively, I get what I expect: the search procedure described above using &lt;em&gt;grep&lt;/em&gt; gives me exactly the same results.&lt;/p&gt;&#10;&lt;h3 id="check-jail"&gt;Check Jail&lt;/h3&gt;&#10;&lt;p&gt;The next step is to test whether the rate limiter is working. To do this, I simulate log entries. I significantly ease jail rules for this test, otherwise I&amp;rsquo;ll have to generate too many entries in a short period of time.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /opt/fail2ban/jail.local TEST&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#ae81ff"&gt;...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;findtime = 10s&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;maxretry = 2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;bantime = 100s&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After restarting &lt;em&gt;fail2ban&lt;/em&gt;, I create a second console where I can simulate log entries. This must include at least the time, IP address of the caller, and status; however, I play it safe and use complete entries.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo &lt;span style="color:#e6db74"&gt;&amp;#39;{&amp;#34;ts&amp;#34;:1751246139,&amp;#34;remote_ip&amp;#34;:&amp;#34;1.1.1.1&amp;#34;,&amp;#34;status&amp;#34;:200,[superLongIrrelevantOtherStuffForFiltering]}&amp;#39;&lt;/span&gt; &amp;gt;&amp;gt; /var/log/caddy/blog/access.log&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I enter this multiple times to exceed my limit of 2 retries.&lt;/p&gt;&#10;&lt;p&gt;In the first console instance, I then check the &lt;em&gt;fail2ban&lt;/em&gt; status for the corresponding filter.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;fail2ban-client status caddy-ratelimit&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Status &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; the jail: caddy-ratelimit&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;|- Filter&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;| |- Currently failed: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;| |- Total failed: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;| &lt;span style="color:#e6db74"&gt;`&lt;/span&gt;- File list: /var/log/caddy2/blog/access.log&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;- Actions&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;|- Currently banned: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;|- Total banned: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;- Banned IP list:&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That was nothing. The fail2ban log file reveals why:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /fail2ban/config/log/fail2ban/fail2ban.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;WARN &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-ratelimit&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Ignoring all log entries older than 20s; &lt;span style="color:#75715e"&gt;# probably messages generated within a fail2ban restart period&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So again with appropriate timestamps. I have to get these from the current system time (&lt;code&gt;$(date +%s.%N)&lt;/code&gt;) if I have to constantly adjust it. Therefore, I take a complete log line, modify it to the IP address &lt;code&gt;1.1.1.1&lt;/code&gt;, and insert appropriate timestamps:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;echo&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;{\&amp;#34;level\&amp;#34;:\&amp;#34;info\&amp;#34;,\&amp;#34;ts\&amp;#34;:$(date +%s.%N),\&amp;#34;logger\&amp;#34;:\&amp;#34;http.log.access.log0\&amp;#34;,\&amp;#34;msg\&amp;#34;:\&amp;#34;handled request\&amp;#34;,\&amp;#34;request\&amp;#34;:{\&amp;#34;remote_ip\&amp;#34;:\&amp;#34;1.1.1.1\&amp;#34;,\&amp;#34;remote_port\&amp;#34;:\&amp;#34;38229\&amp;#34;,\&amp;#34;client_ip\&amp;#34;:\&amp;#34;1.1.1.1\&amp;#34;,\&amp;#34;proto\&amp;#34;:\&amp;#34;HTTP/1.1\&amp;#34;,\&amp;#34;method\&amp;#34;:\&amp;#34;GET\&amp;#34;,\&amp;#34;host\&amp;#34;:\&amp;#34;blog.schallbert.de\&amp;#34;,\&amp;#34;uri\&amp;#34;:\&amp;#34;/vacuum-clamping/\&amp;#34;,\&amp;#34;headers\&amp;#34;:{\&amp;#34;Accept-Encoding\&amp;#34;:[\&amp;#34;gzip, deflate, br\&amp;#34;],\&amp;#34;Connection\&amp;#34;:[\&amp;#34;keep-alive\&amp;#34;],\&amp;#34;User-Agent\&amp;#34;:[\&amp;#34;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0\&amp;#34;],\&amp;#34;Accept\&amp;#34;:[\&amp;#34;text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\&amp;#34;],\&amp;#34;Accept-Language\&amp;#34;:[\&amp;#34;en-US,en;q=0.5\&amp;#34;]},\&amp;#34;tls\&amp;#34;:{\&amp;#34;resumed\&amp;#34;:false,\&amp;#34;version\&amp;#34;:772,\&amp;#34;cipher_suite\&amp;#34;:4865,\&amp;#34;proto\&amp;#34;:\&amp;#34;http/1.1\&amp;#34;,\&amp;#34;server_name\&amp;#34;:\&amp;#34;blog.schallbert.de\&amp;#34;}},\&amp;#34;bytes_read\&amp;#34;:0,\&amp;#34;user_id\&amp;#34;:\&amp;#34;\&amp;#34;,\&amp;#34;duration\&amp;#34;:0.001207354,\&amp;#34;size\&amp;#34;:12290,\&amp;#34;status\&amp;#34;:200,\&amp;#34;resp_headers\&amp;#34;:{\&amp;#34;Last-Modified\&amp;#34;:[\&amp;#34;Tue, 10 Jun 2025 19:33:01 GMT\&amp;#34;],\&amp;#34;Content-Encoding\&amp;#34;:[\&amp;#34;gzip\&amp;#34;],\&amp;#34;Server\&amp;#34;:[\&amp;#34;Caddy\&amp;#34;],\&amp;#34;Alt-Svc\&amp;#34;:[\&amp;#34;h3=\\\&amp;#34;:443\\\&amp;#34;; ma=2592000\&amp;#34;],\&amp;#34;Vary\&amp;#34;:[\&amp;#34;Accept-Encoding\&amp;#34;],\&amp;#34;Etag\&amp;#34;:[\&amp;#34;\\\&amp;#34;gzip\\\&amp;#34;\&amp;#34;],\&amp;#34;Content-Type\&amp;#34;:[\&amp;#34;text/html; charset=utf-8\&amp;#34;]}}&amp;#34;&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#960050;background-color:#1e0010"&gt;access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After calling this command several times, I now get&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Status &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; the jail: caddy-blog-ratelimit&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;|- Filter&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;| |- Currently failed:&#9;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;| |- Total failed:&#9;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;| &lt;span style="color:#e6db74"&gt;`&lt;/span&gt;- File list:&#9;/var/log/caddy2/blog/access.log&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;- Actions&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |- Currently banned:&#9;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |- Total banned:&#9;&lt;span style="color:#ae81ff"&gt;4&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;`&lt;/span&gt;- Banned IP list:&#9;1.1.1.1&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That looks great.&lt;/p&gt;&#10;&lt;h3 id="check-packet-filter"&gt;Check packet filter&lt;/h3&gt;&#10;&lt;p&gt;Phew, finally done! This is how &lt;code&gt;fail2ban.log&lt;/code&gt; looks like right now. It shows &lt;code&gt;[caddy-ratelimit]&lt;/code&gt; entries as expected:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# nano /fail2ban/config/log/fail2ban/fail2ban.log&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-ratelimit&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP a&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-status&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP b&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-ratelimit&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Ignore 172.18.0.1 by ip&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-status&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Ignore 172.18.0.1 by ip&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-ratelimit&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP c&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-ratelimit&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP c&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-status&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP c&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-status&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP c&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-ratelimit&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP d&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-status&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP d&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-status&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP e&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-ratelimit&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP e&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &amp;lt;timestamp&amp;gt; &amp;lt;id&amp;gt; INFO &lt;span style="color:#f92672"&gt;[&lt;/span&gt;caddy-ratelimit&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Found &amp;lt;IP f&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To test the entire chain, we now need to check whether the corresponding IP address is actually blocked in the hardware. To do this, I enter:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# iptables -n -L | grep &lt;span style="color:#e6db74"&gt;&amp;#34;1.1.1.1&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;REJECT all -- 1.1.1.1 0.0.0.0/0 reject-with icmp-port-unreachable&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Everything seems to be fine. For one final test from an external IP address, I call a &lt;a href="https://www.deadlinkchecker.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Link Checker&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; in the browser, which should crawl my website and thus trigger the rate limiter. Although it crawls 44 links in a very short time, the test is displayed as &amp;ldquo;passed.&amp;rdquo; Very strange, &lt;code&gt;[caddy-ratelimit]&lt;/code&gt; should have triggered!&lt;/p&gt;&#10;&lt;h3 id="iptable-chain-input-instead-of-forward"&gt;iptable-chain INPUT instead of FORWARD&lt;/h3&gt;&#10;&lt;p&gt;Confused, I take a look at the entire &lt;code&gt;iptable&lt;/code&gt;. At the same time, I look up the IP address of the link checker. It appears in the &lt;code&gt;iptables&lt;/code&gt;. And yet, it&amp;rsquo;s apparently not blocked. Why is that? A closer look shows:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;Chain INPUT (policy ACCEPT)&#10;target prot opt source destination&#10;f2b-caddy-ratelimit tcp -- 0.0.0.0/0 0.0.0.0/0 multiport dports 22&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The problem here is that my jail is on the &lt;code&gt;INPUT&lt;/code&gt; chain. However, the requests don&amp;rsquo;t go directly to my server hardware, but are forwarded via &lt;em&gt;Docker&lt;/em&gt; to &lt;em&gt;Caddyserver&lt;/em&gt;. To work, I have to land on the &lt;code&gt;FORWARD&lt;/code&gt; chain, where &lt;code&gt;DOCKER-USER&lt;/code&gt; already is. Very strange, since I had specifically specified &lt;code&gt;chain = DOCKER-USER&lt;/code&gt; in &lt;code&gt;jail.local&lt;/code&gt;. Something must be overriding this definition.&lt;/p&gt;&#10;&lt;p&gt;An inconspicuous &lt;a href="https://gist.github.com/Rankarusu/23a04ed587b05c6f2b701f2457a127b0?permalink_comment_id=5347631#gistcomment-5347631" target="_blank" rel="noopener noreferrer" class="external-link"&gt;forum post&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; together with the &lt;code&gt;multiport&lt;/code&gt; comment in the iptables printout led me to the solution: The &lt;code&gt;action = iptables-multiport&lt;/code&gt; directive overrides my &lt;code&gt;chain = DOCKER-USER&lt;/code&gt; statement, because the following is set in the corresponding configuration file &lt;code&gt;iptables.conf&lt;/code&gt;: &lt;code&gt;chain = INPUT&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;So I simply delete the &lt;code&gt;action&lt;/code&gt; entry, so that &lt;em&gt;fail2ban&lt;/em&gt; reverts to the default for &lt;code&gt;DOCKER-USER&lt;/code&gt;: &lt;code&gt;multiport dports 80,443&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Now the jail name also appears correctly in &lt;code&gt;iptables&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert@machine:~# iptables -n -L&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Chain DOCKER-USER &lt;span style="color:#f92672"&gt;(&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt; references&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;target prot opt source destination&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;f2b-caddy-ratelimit tcp -- 0.0.0.0/0 0.0.0.0/0 multiport dports 80,443&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Finally, another link check shows that my rate limiter is working.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-07-10-deadlinkcheck-ratelimit-working.avif" alt="Image: deadlinkchecker view for blog.schallbert.de hits configured rate limit and gets blocked subsequently. Thus it returns a Timeout"&gt;&lt;/figure&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Fix &amp;#39;Bad value substitution&amp;#39; error&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2025-10-09T00:00:00Z"&gt;&#10; 2025-10-09&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; There is an important update that fixes &lt;em&gt;fail2ban&lt;/em&gt;&amp;rsquo;s &lt;a href="https://blog.schallbert.de/en/fail2ban-error-configuration-bad-value/"&gt;Error: &amp;lsquo;Bad value substitution&amp;rsquo; for &amp;lsquo;action&amp;rsquo;&lt;/a&gt;. This problem can emerge when &lt;em&gt;fail2ban&lt;/em&gt; is protecting services that run in a Docker container environment.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;</description></item><item><title>Gitea crashes: Too many requests</title><link>https://blog.schallbert.de/en/gitea-out-of-memory/</link><pubDate>Mon, 30 Jun 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/gitea-out-of-memory/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-06-30-server-load-thumb.avif"&#10; class="post-cover"&#10; alt="Image: server at max load when gitea is flooded with GET requests"&#10; title="Gitea crashes: Too many requests" /&gt;&#10;&lt;p&gt;Here I&amp;rsquo;d like to briefly describe how a service crash repeatedly paralyzed my server for hours. So badly that I could only restart it from the provider&amp;rsquo;s console. I&amp;rsquo;ll explain how it happened and how I intend to avoid this and similar problems in the future.&lt;/p&gt;&#10;&lt;h2 id="i-was-attacked-or-was-i"&gt;I was attacked. Or was I?&lt;/h2&gt;&#10;&lt;p&gt;I was working on an article that I wanted to post later. To be on the safe side in terms of backup, I created a commit as usual and wanted to push it to my &lt;em&gt;Gitea&lt;/em&gt; instance. But my &lt;code&gt;git push&lt;/code&gt; command simply didn&amp;rsquo;t work.&lt;/p&gt;&#10;&lt;p&gt;Confused, I tried to access my website. The response was:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-06-30-404-message.avif" alt="Image: A browser&amp;#39;s timeout error message `Could not complete your request`"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Strange. Then I wanted to log in to my server to check everything was OK: &lt;code&gt;ssh &amp;lt;servername&amp;gt;&lt;/code&gt;. Again, the terminal remained unresponsive. Bummer!&lt;/p&gt;&#10;&lt;p&gt;Timeout. As a last resort, I logged in to my hoster&amp;rsquo;s web interface and looked at the server&amp;rsquo;s graphs:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-06-30-server-load.avif" alt="Image: 200% CPU load on my server for nearly two hours"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Oh, what&amp;rsquo;s going on? I&amp;rsquo;m trying to shut down the server via the web interface. That doesn&amp;rsquo;t work either. Only a hard reboot succeeds.&#10;I can log in again via SSH and see that all &lt;em&gt;Docker&lt;/em&gt; containers are booting up normally.&lt;/p&gt;&#10;&lt;h2 id="what-happened"&gt;What happened?&lt;/h2&gt;&#10;&lt;p&gt;It&amp;rsquo;s a good thing I keep all the logs for a week. This, along with the machine&amp;rsquo;s utilization over time, allows me to reconstruct what happened. At least to some extent.&lt;/p&gt;&#10;&lt;h3 id="which-logs-help"&gt;Which logs help?&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Localize the crash! Find the triggering application in the &lt;code&gt;kern.log&lt;/code&gt; and note the timestamp.&lt;/li&gt;&#10;&lt;li&gt;Are there system-wide effects or other services being affected? Check the &lt;code&gt;syslog&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;If you suspect the system may have been hacked, &lt;code&gt;auth.log&lt;/code&gt; has the details.&lt;/li&gt;&#10;&lt;li&gt;If the affected application is running in a container, the relevant logs there may be helpful.&lt;/li&gt;&#10;&lt;li&gt;Review the application&amp;rsquo;s logs. The time just before the crash is particularly interesting.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;And here again, in detail, are the logs I reviewed for my behavior and where they can be found.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;log name&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;purpose&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;relevant content for this issue&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;/var/log/syslog&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;System-wide (bare metal) messages&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;(docker) warning: &amp;ldquo;health check for container &lt;code&gt;&amp;lt;ID&amp;gt;&lt;/code&gt; timeout&amp;rdquo; (containerd) error: &amp;ldquo;ttrpc: received message on inactive stream&amp;rdquo;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;/var/log/auth.log&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Contains authentication messages from external hosts, in my case mostly SSH&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;None&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;/var/log/kern.log&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Logs all app, service, daemon and system crashes&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Out of memory: Killed process &lt;code&gt;&amp;lt;ID&amp;gt;&lt;/code&gt; (&lt;code&gt;&amp;lt;serviceName&amp;gt;&lt;/code&gt;)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;[.]/gitea/log/gitea.log&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Protocol for requests, actions on Gitea&amp;rsquo;s web interface, repo changes etc.&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;GET&lt;/code&gt; requests, crash/restart indications&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;docker container logs &lt;code&gt;&amp;lt;containerID&amp;gt;&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Docker&amp;rsquo;s logs for the container in question&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Received signal 15; terminating. (SIGTERM)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h3 id="kernlogsyslog"&gt;kern.log/syslog&lt;/h3&gt;&#10;&lt;p&gt;Here, next to the stack trace, I can see exactly what happened. The most understandable message is the following:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;Out of memory: Killed process `&amp;lt;ID&amp;gt;` (gitea)&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So, Gitea was unplugged because it had consumed practically all system resources.&#10;If I scroll up in the log, I see a few minutes earlier:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;timestamp&amp;gt; &amp;lt;machineName&amp;gt; dockerd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;716&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: level&lt;span style="color:#f92672"&gt;=&lt;/span&gt;warning msg&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Health check for container &amp;lt;ID&amp;gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;timestamp&amp;gt; &amp;lt;machineName&amp;gt; dockerd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;716&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: level&lt;span style="color:#f92672"&gt;=&lt;/span&gt;error msg&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;stream copy error: reading from a closed fifo&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Already here, I see warnings that the &lt;em&gt;gitea&lt;/em&gt; container isn&amp;rsquo;t working properly. Another advantage of including &amp;ldquo;health checks&amp;rdquo; in the &lt;code&gt;docker-compose.yml&lt;/code&gt; file. For other reasons, I&amp;rsquo;ve already written an article about the purpose and implementation of &lt;a href="https://blog.schallbert.de/en/fix-gitea-runner/#healthcheck"&gt;health checks in &lt;em&gt;docker&lt;/em&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="gitealog"&gt;gitea.log&lt;/h3&gt;&#10;&lt;p&gt;The following log entry indicates that &lt;em&gt;Gitea&lt;/em&gt; has just been restarted due to a bug in a submodule:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;cmd/web.go:205:serveInstalled&lt;span style="color:#f92672"&gt;()&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;W&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Table system_setting Column version db default is , struct default is &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And further up in the log:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-06-30-attack-log.avif" alt="Image: Gitea log showing numerous GET request entries, many of which for a specific large file (MiB range), following a typical Gitea startup message"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Very interesting. The &lt;code&gt;highlight.css&lt;/code&gt; is in my public repo. It&amp;rsquo;s the scheme for &lt;a href="https://lectures.schallbert.de/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;lectures.schallbert.de&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;rsquo;s appearance. This file is quite large, almost 1 MiB. And it&amp;rsquo;s loaded here dozens of times, practically for every commit.&lt;/p&gt;&#10;&lt;p&gt;Now I&amp;rsquo;m looking at other crashes in the past. It&amp;rsquo;s always bursts of GET commands for large files or requests for compares between two branches of the repository that precede my server crashing.&lt;/p&gt;&#10;&lt;h2 id="whos-behind-this"&gt;Who&amp;rsquo;s behind this?&lt;/h2&gt;&#10;&lt;p&gt;All crash-triggering requests come from the same IP address range. The crashes started a few weeks ago. But mostly at times of day that I(and apparently many of my readers) didn&amp;rsquo;t notice. And after a few minutes, the server was always back to normal operation.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-06-30-gcp-dos.avif" alt="Image: Whois request for the IP that had my server crashed, owner: Google LLC (GCP)"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Oh, the trail leads to Google&amp;rsquo;s Cloud Platform (GCP).&lt;/p&gt;&#10;&lt;h3 id="but-i-had-blocked-robots"&gt;But I had blocked robots?&lt;/h3&gt;&#10;&lt;p&gt;Indeed, I had &lt;a href="https://blog.schallbert.de/gitea-search-indexation/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;stopped the search engine indexing for Gitea&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Strange. Or am I not dealing with one of the Google spiders or &amp;ldquo;AI&amp;rdquo; scrapers, but with a nasty hacker who rented a virtual machine from &amp;ldquo;Google Cloud&amp;rdquo;?&lt;/p&gt;&#10;&lt;h3 id="log-research-how-frequently-is-the-file-requested"&gt;Log research: How frequently is the file requested?&lt;/h3&gt;&#10;&lt;p&gt;A spider would only crawl all my pages once every few weeks, right? And hopefully not ignore my &lt;code&gt;robots.txt&lt;/code&gt;. A crawler certainly wouldn&amp;rsquo;t make the same request multiple times and at short intervals, would it?&lt;/p&gt;&#10;&lt;p&gt;To check this, I search the &lt;em&gt;Gitea&lt;/em&gt; logs for entries of GET requests to one of the large and therefore resource-intensive files to transfer:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gunzip gitea.log.&amp;lt;date.rotateID&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;grep &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;lt;filename&amp;gt;&amp;#34;&lt;/span&gt; gitea.log.&amp;lt;date.rotateID&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In the list, I see that the same request to the same target file is made multiple times from the same IP address, all within seconds. Shortly before the crash, my server took almost 4 seconds to serve the request.&lt;/p&gt;&#10;&lt;p&gt;I also see that the IP address changes every few hours.&lt;/p&gt;&#10;&lt;h2 id="countering-a-dos-attack"&gt;Countering a DoS Attack&lt;/h2&gt;&#10;&lt;p&gt;In summary, I have to conclude that I&amp;rsquo;m being attacked via a &lt;a href="https://en.wikipedia.org/wiki/Denial-of-service_attack" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Denial-of-service&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; attack from the GCP address space.&lt;/p&gt;&#10;&lt;p&gt;To gather a bit more background information, I visit a few websites on the topic. There, I learn that &lt;em&gt;gitea&lt;/em&gt; on my server is crashing under an &lt;a href="https://www.geeksforgeeks.org/computer-networks/types-of-dos-attacks/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Application Layer Attack&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Now that I know what&amp;rsquo;s happening and what the problem is, finding solutions is much easier 😅&lt;/p&gt;&#10;&lt;p&gt;Of course, I don&amp;rsquo;t want to give up without a fight by permanently taking my Gitea instance offline. So, what options do I have?&lt;/p&gt;&#10;&lt;h3 id="provide-more-resources"&gt;Provide more resources&lt;/h3&gt;&#10;&lt;p&gt;Admittedly, my machine only has &lt;code&gt;40GB&lt;/code&gt; of memory and &lt;code&gt;2GB&lt;/code&gt; of VRAM, as well as a measly 2-core CPU from 2009. I could book a more powerful server to better handle peak loads. But this wouldn&amp;rsquo;t prevent the attack, only mitigate its effects.&lt;/p&gt;&#10;&lt;h3 id="rate-limiting-directly-in-the-web-server"&gt;Rate limiting directly in the web server&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Rate_limiting" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Rate limiters&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; limit the number of requests a client can make within a certain time window. This conserves server resources. This is a &amp;ldquo;soft&amp;rdquo; defense against DOS attacks, as triggering IP addresses are briefly and gently blocked with an error message. Typically, &lt;code&gt;HTTP status code 429&lt;/code&gt; &amp;ldquo;Too Many Requests&amp;rdquo; is returned when the limiter intervenes.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;Gitea&lt;/em&gt; doesn&amp;rsquo;t have a rate limiter. In &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/#web-access"&gt;my setup&lt;/a&gt;, &lt;em&gt;Gitea&lt;/em&gt; runs behind a reverse proxy provided by my &lt;em&gt;Caddyserver&lt;/em&gt;. So, that&amp;rsquo;s where I&amp;rsquo;d have to start. For &lt;em&gt;Caddy&lt;/em&gt;, rate limiters are only available as &lt;a href="https://caddyserver.com/docs/modules/http.handlers.rate_limit" target="_blank" rel="noopener noreferrer" class="external-link"&gt;external modules&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, which must be manually installed and configured in &lt;em&gt;xcaddy&lt;/em&gt;.&lt;/p&gt;&#10;&lt;h3 id="blocking-with-ip-table-rules"&gt;Blocking with IP-table rules&lt;/h3&gt;&#10;&lt;p&gt;Here, you could again use &lt;em&gt;fail2ban&lt;/em&gt; and simply block multiple requests for the same resource from an IP address. &lt;em&gt;Gitea&lt;/em&gt; has a &lt;a href="https://docs.gitea.com/next/administration/fail2ban-setup" target="_blank" rel="noopener noreferrer" class="external-link"&gt;description of the setup in its documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. In my case, I would have to continue &lt;a href="https://blog.schallbert.de/en/server-protection/#what-does-not-yet-work-gitea--fail2ban"&gt;where I left off&lt;/a&gt;, and thus monitor not only SSH but also normal page requests.&lt;/p&gt;&#10;&lt;p&gt;This solution sounds the most sensible to me, as it clearly separates concerns into different applications. I only use applications I already have available: &lt;em&gt;Caddy&lt;/em&gt; would provide the access logs, and &lt;em&gt;fail2ban&lt;/em&gt; would have to read them and set filters in the &lt;code&gt;jail.local&lt;/code&gt; configuration so that it acts like a rate limiter.&lt;/p&gt;&#10;&lt;p&gt;Let me link the follow-up article &lt;a href="https://blog.schallbert.de/en/fail2ban-with-caddy/"&gt;Setting up Fail2ban with Caddy&lt;/a&gt; here 🙂&lt;/p&gt;&#10;</description></item><item><title>My own webshop - Part3</title><link>https://blog.schallbert.de/en/ecommerce-selection/</link><pubDate>Sun, 15 Jun 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/ecommerce-selection/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-06-15-opencart-thumb.avif"&#10; class="post-cover"&#10; alt="Image: Snip of OpenCart&amp;#39;s article about data safety"&#10; title="My own webshop - Part3" /&gt;&#10;&lt;h2 id="selecting-a-solution-based-on-key-questions"&gt;Selecting a solution based on key questions&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Which solution is straightforward and quick to set up?&lt;/li&gt;&#10;&lt;li&gt;Which solution is easier to maintain?&lt;/li&gt;&#10;&lt;li&gt;With which system can I, as the operator, most easily understand how everything works (transparency, dependencies)?&lt;/li&gt;&#10;&lt;li&gt;Which system has the smallest attack surface?&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="theres-still-quite-a-bit-missing-here"&gt;There&amp;rsquo;s still quite a bit missing here!&lt;/h2&gt;&#10;&lt;p&gt;Over the last few weeks, my enthusiasm for setting up my own online shop has waned a bit, as I wanted to focus first on securing my server and finishing off some old, nearly completed projects. I&amp;rsquo;ll return to this series as soon as that ‘I-want-to-do-something-new&amp;rsquo; fever takes hold of me again.&lt;/p&gt;&#10;&lt;p&gt;Hang in there!&#10;&lt;em&gt;Schallbert&lt;/em&gt;&lt;/p&gt;&#10;</description></item><item><title>Creating an own Webshop - Part 2"</title><link>https://blog.schallbert.de/en/ecommerce-alternatives/</link><pubDate>Thu, 15 May 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/ecommerce-alternatives/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-05-15-ecommerce-frameworks-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: logos from different e-commerce solutions"&#10; title="Creating an own Webshop - Part 2&amp;#34;" /&gt;&#10;&lt;h2 id="a-few-options-for-webshop-software"&gt;A few options for webshop software&lt;/h2&gt;&#10;&lt;p&gt;Let&amp;rsquo;s assume only software that at least partially meets my &lt;a href="https://blog.schallbert.de/en/ecommerce-requirements/"&gt;requirements&lt;/a&gt; get shortlisted. Below, I would like to present a few possible ecommerce solutions.&lt;/p&gt;&#10;&lt;h3 id="shopify"&gt;Shopify&lt;/h3&gt;&#10;&lt;p&gt;Shopify is an all-inclusive package solution for online retail. Shopify Inc. is headquartered in Canada and handles its European business from Ireland. The &lt;a href="https://www.shopify.com/de/legal" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Legal&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; sections found on the website, as well as the complexity of the information presented on the topic of &lt;a href="https://www.shopify.com/de/legal/impressum?country=de&amp;amp;lang=en" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Data Protection&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, lead me to suspect that Shopify is a multinational corporation - and not just a provider of online shops.&lt;/p&gt;&#10;&lt;p&gt;But back to the topic.&lt;/p&gt;&#10;&lt;p&gt;The domain and hosting are included in the price with Shopify, and you can create your own shop rapidly and without any programming knowledge in a browser window by selecting a theme, and setting a few parameters. Shopify has extensive documentation and offers training and all sorts of other services for its subscribers.&lt;/p&gt;&#10;&lt;p&gt;But where&amp;rsquo;s the fun in that? After all, I run a tech blog and am not a businessman, literally. A bit of programming is definitely something I enjoy. Furthermore, as an open source fan, I don&amp;rsquo;t particularly like the corporate stance (just look at their modern website, equipped with auto-playing videos and optimized for marketing purposes). The configurability is mediocre at best, and, as with most other solutions, I have to do the optimization for search engine results myself. Regarding data protection and security, they maintain the usual compliance and state-of-the-art communication; so I can&amp;rsquo;t look behind it and have to assume that they protect their customers well, also in their own interest.&lt;/p&gt;&#10;&lt;p&gt;Shopify describes itself as a &lt;a href="https://www.shopify.com/blog/open-source-ecommerce#5" target="_blank" rel="noopener noreferrer" class="external-link"&gt;closed-source SaaS platform&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Therefore, I&amp;rsquo;m removing this platform from my list of preferred web shop solutions because I don&amp;rsquo;t want to become unnecessarily dependent on the well-being of third parties. With Shopify, it&amp;rsquo;s not me who has control over subscription costs or &amp;ldquo;my&amp;rdquo; share of the cloud, but Shopify. It&amp;rsquo;s not me who can determine how many transactions are included in the &amp;ldquo;plan,&amp;rdquo; but Shopify. It&amp;rsquo;s not my customers who remain in control of their data, but Shopify or a third-party company they have commissioned to process it.&lt;/p&gt;&#10;&lt;h3 id="wordpress--woocommerce"&gt;Wordpress / WooCommerce&lt;/h3&gt;&#10;&lt;p&gt;I only know Wordpress as a juggernaut. The jack of all trades, capable of everything you could possibly want thanks to an overwhelming mass of plugins, extensions, and features. And much more you don&amp;rsquo;t need.&lt;/p&gt;&#10;&lt;p&gt;But it&amp;rsquo;s open source, free, and, according to its own statements, used by many, many shop operators. Let&amp;rsquo;s take a look at the most well-known e-commerce extension for Wordpress: &lt;a href="https://woocommerce.com/document/build-online-store/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;WooCommerce&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;I immediately found countless guides on setting up a WooCommerce store (for example, from &lt;a href="https://www.greengeeks.com/blog/set-up-woocommerce-wordpress-ultimate-guide/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;GeeksForGeeks&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; or &lt;a href="https://themeisle.com/blog/how-to-set-up-woocommerce/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Themeisle&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;), so I&amp;rsquo;ll refrain from providing my own guide.&lt;/p&gt;&#10;&lt;p&gt;After skimming through a few tutorials, I&amp;rsquo;ll summarize:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A WooCommerce store is easy to set up and configure using a wizard.&lt;/li&gt;&#10;&lt;li&gt;There are many themes available, and easy-to-integrate store designs offer a suitable look for every taste.&lt;/li&gt;&#10;&lt;li&gt;It helps to already have a WordPress site and some basic knowledge of domains, DNS, and hosting.&lt;/li&gt;&#10;&lt;li&gt;Many payment service providers and some shipping providers are preconfigured.&lt;/li&gt;&#10;&lt;li&gt;There are numerous YouTube tutorial videos on the topic, and detailed documentation is also available.&lt;/li&gt;&#10;&lt;li&gt;Countless plugins allow you to optimize your store for even very specialized areas and purposes.&lt;/li&gt;&#10;&lt;li&gt;However, this variety of options can be offset by the fact that choosing the &amp;ldquo;best solution&amp;rdquo; can be difficult, and optimizing the site for speed and search engines can become complex.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;All in all, WooCommerce strikes me as a balanced mix of &amp;ldquo;I want to quickly put together something useful&amp;rdquo; and &amp;ldquo;I want to be able to configure and modify everything.&amp;rdquo;&lt;/p&gt;&#10;&lt;h3 id="snipcart"&gt;Snipcart&lt;/h3&gt;&#10;&lt;p&gt;I would consider Snipcart a &amp;ldquo;shopping plugin&amp;rdquo; for any website. Even static websites that can essentially only display a product catalog become a fully-fledged eCommerce solution with Snipcart. The core element here is the shopping cart, which is used to process the purchase.&lt;/p&gt;&#10;&lt;p&gt;The interface is the website&amp;rsquo;s &lt;code&gt;HTML&lt;/code&gt; markup. Snipcart is integrated as a &lt;code&gt;JavaScript&lt;/code&gt; blob and referenced via &lt;code&gt;HTML&lt;/code&gt;. The design and appearance are handled via cascading style sheets. In my case, I could use the existing &amp;ldquo;tech stack&amp;rdquo; consisting of a static Jekyll site with a CD pipeline in Gitea on a Caddyserver instance for the shop as well, and with minimal adjustments, conjure a seamless look for the blog and shop. Sounds great, right?&lt;/p&gt;&#10;&lt;p&gt;Snipcart is developed by a Canadian company in Quebec. It describes itself as a &lt;a href="https://snipcart.com/ecommerce-inventory-management" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&amp;ldquo;Shopping Cart Platform&amp;rdquo;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. An account is required for installation, and you must enter your shopping domain in the Snipcart web frontend, similar to Google Search Console or Google Analytics, to receive a so-called API key. Only with this key will the JavaScript blob integrate correctly.&lt;/p&gt;&#10;&lt;p&gt;And this brings us to the first major problem: Snipcart restricts my freedom.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-html" data-lang="html"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;link&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;rel&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;preconnect&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&amp;lt;https://app.snipcart.com&amp;gt;&amp;#34;&lt;/span&gt; /&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;link&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;rel&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;preconnect&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;href&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&amp;lt;https://cdn.snipcart.com&amp;gt;&amp;#34;&lt;/span&gt; /&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This shows me that Snipcart would like to keep the data on its server. I have to use their content delivery network to handle the order process and integrate it with mine. So, it&amp;rsquo;s by no means the case that all connections remain with me and I &amp;ldquo;buy&amp;rdquo; the JS code from Snipcart and run it in the customer&amp;rsquo;s browser or a database solution behind it on my server.&lt;/p&gt;&#10;&lt;p&gt;So, if full autonomy and control over your own site isn&amp;rsquo;t a priority, Snipcart can be a very elegant e-commerce solution. This is especially worthwhile for existing sites. However, for me, it&amp;rsquo;s out of the question.&lt;/p&gt;&#10;&lt;h3 id="opencart"&gt;OpenCart&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://www.opencart.com/index.php?route=common/home" target="_blank" rel="noopener noreferrer" class="external-link"&gt;OpenCart&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; offers a PHP-based shop module free of charge and open source. It can be booked as &amp;ldquo;Software as a Service&amp;rdquo; through third-party providers or downloaded via &lt;a href="https://github.com/opencart/opencart/blob/master/INSTALL.md" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and deployed on your own server.&lt;/p&gt;&#10;&lt;p&gt;Installation and administration are handled via web interface and, in my opinion, are straightforward. You can also find a few images on DockerHub that you can get started with right away.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-05-15-opencart.avif" alt="Image: OpenCart demo, category overview"&gt;&lt;/figure&gt;&#10;&lt;p&gt;OpenCart is fully customizable to your needs. Templated themes can also be integrated. Out of curiosity, I visited a few websites that use OpenCart: In my opinion, the shop system is well-structured and easy to use. However, the loading speed of the sample pages isn&amp;rsquo;t always super fast.&lt;/p&gt;&#10;&lt;p&gt;Overall, I consider OpenCart, with its customizable, lightweight shop solution in PHP, to be a good solution - also thanks to its still-active community (13+ years!).&lt;/p&gt;&#10;&lt;h3 id="django"&gt;Django&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://www.djangoproject.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Django&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is a web framework for Python. It offers many features, allows extensions by simply including Python modules, and allows even beginners to quickly create working solutions. Although there are numerous libraries for websites, shops &lt;a href="https://django-shop.readthedocs.io/en/latest/tutorial/intro.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;such as django-SHOP&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, database integrations, integration of payment systems, etc., everything still has to be put together and configured by the developer.&lt;/p&gt;&#10;&lt;p&gt;While this gives you full control and a solution that&amp;rsquo;s perfectly tailored to your needs, it also has a significant drawback: learning time. Database security must be ensured by the developer, and user input validation must be programmed or at least correctly configured. Customer master data is stored in plain text by default. Access and deletion concepts must be created and implemented by the developer, or time should be allowed to identify and integrate suitable modules.&lt;/p&gt;&#10;&lt;p&gt;If frameworks are to be used, it is recommended to check for active community maintenance beforehand. For example, it looks to me as if there hasn&amp;rsquo;t been much activity on the Github repo for &lt;a href="https://github.com/awesto/django-shop" target="_blank" rel="noopener noreferrer" class="external-link"&gt;django-SHOP&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; since 2021. A quick search, however, reveals &lt;a href="https://github.com/topics/django-ecommerce" target="_blank" rel="noopener noreferrer" class="external-link"&gt;a lot of alternatives&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; - now it&amp;rsquo;s time to consider something suitable or reinvent the wheel yourself 😃&lt;/p&gt;&#10;&lt;h2 id="the-alternative-for-beginners-like-me-no-shop-system"&gt;The alternative for beginners like me: no shop system&lt;/h2&gt;&#10;&lt;p&gt;What is meant by &amp;ldquo;no shop system&amp;rdquo;? A form -based approach. Here the user looks at a static website that contains catalog and product pages. In addition, a few elements are shown: E.g. a drop-down menu for variant and a field for the number of pieces. Next to it a simple button. If this is clicked, a form opens that automatically transmits the clicked content and asks about the user&amp;rsquo;s contact details.&lt;/p&gt;&#10;&lt;p&gt;If everything is filled in properly, the form creates a PGP-encrypted email to me when sending. I can then view the order analogously to manually created order emails, send an order confirmation and request payment.&lt;/p&gt;&#10;&lt;p&gt;With a few products, a shop equivalent can be implemented supporting a few customers without much effort. Of course not as comfortable as with an eCommerce solution. But maybe that&amp;rsquo;s enough for the beginning.&lt;/p&gt;&#10;&lt;h2 id="in-the-next-article"&gt;In the next article&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://blog.schallbert.de/en/ecommerce-selection/"&gt;Selection and Implementation&lt;/a&gt;&lt;/p&gt;&#10;</description></item><item><title>Creating an own Webshop - Part 1</title><link>https://blog.schallbert.de/en/ecommerce-requirements/</link><pubDate>Tue, 15 Apr 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/ecommerce-requirements/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-04-15-ecommerce-pt1-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: text saying &amp;#39;&amp;#39;E-commerce Pt1: Requirements of a technophiliac&amp;#39;&amp;#39;"&#10; title="Creating an own Webshop - Part 1" /&gt;&#10;&lt;p&gt;This is part one of a series of articles about building my own webshop. I&amp;rsquo;m listing my requirements and wishes for my e-commerce solution. This way I narrow it down to a few suitable software solutions that I could later use for my webshop.&lt;/p&gt;&#10;&lt;h2 id="what-my-webshop-needs-to-be-able-to-do"&gt;What my webshop needs to be able to do&lt;/h2&gt;&#10;&lt;p&gt;For me as owner, the webshop should operate as lean, transparent, and automated as possible. I want to spend little time on operation, maintenance, and support. Payment processing, order status management, customer registration, sending customer information, and a large number of steps in the shipping process, such as postage and entering shipping confirmations, should be automated or at least prepared for automation.&lt;/p&gt;&#10;&lt;p&gt;But the webshop must also be structured in a way that is understandable for customers: simple and with just a few clicks from the homepage to checkout, easy to use, similar to other common webshops, with straightforward payment processing. As little data as possible should be collected from the customer.&lt;/p&gt;&#10;&lt;p&gt;My webshop only needs to display a few products. Therefore, navigation can be kept very simple. Due to the small number of product categories, a tree structure may be completely avoidable.&lt;/p&gt;&#10;&lt;p&gt;Let&amp;rsquo;s take a detailed look at what features the web shop ultimately needs to provide.&lt;/p&gt;&#10;&lt;h3 id="functions-for-admins"&gt;Functions for Admins&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Create a product&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Title&lt;/li&gt;&#10;&lt;li&gt;Tags&lt;/li&gt;&#10;&lt;li&gt;Short description&lt;/li&gt;&#10;&lt;li&gt;Description&lt;/li&gt;&#10;&lt;li&gt;Images&lt;/li&gt;&#10;&lt;li&gt;Videos&lt;/li&gt;&#10;&lt;li&gt;Price&lt;/li&gt;&#10;&lt;li&gt;Profile / Data sheet / Instructions&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;ol start="2"&gt;&#10;&lt;li&gt;Edit a product&lt;/li&gt;&#10;&lt;li&gt;Remove a product&lt;/li&gt;&#10;&lt;li&gt;Manage orders&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Confirm a step&lt;/li&gt;&#10;&lt;li&gt;Restart a step&lt;/li&gt;&#10;&lt;li&gt;Cancel a process&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;ol start="5"&gt;&#10;&lt;li&gt;Manage customers&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Create (sandbox)&lt;/li&gt;&#10;&lt;li&gt;Delete (data protection)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="functions-for-buyers"&gt;Functions for Buyers&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Browse the webshop&lt;/li&gt;&#10;&lt;li&gt;Configure product(s) or select product variants (e.g., color, quality)&lt;/li&gt;&#10;&lt;li&gt;Add product(s) to a shopping cart&lt;/li&gt;&#10;&lt;li&gt;Manage shopping cart&lt;/li&gt;&#10;&lt;li&gt;Register (optional, only for customer data storage and order management)&lt;/li&gt;&#10;&lt;li&gt;Select shipping&lt;/li&gt;&#10;&lt;li&gt;Make a payment&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="automatic-functions"&gt;Automatic functions&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Payment registration&lt;/li&gt;&#10;&lt;li&gt;Invoice creation and dispatch&lt;/li&gt;&#10;&lt;li&gt;Inventory management&lt;/li&gt;&#10;&lt;li&gt;Shipping notification&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="non-functional-requirements"&gt;Non-functional Requirements&lt;/h2&gt;&#10;&lt;p&gt;Here I list everything the web shop should be able to do &amp;ldquo;on the side.&amp;rdquo; Things that are not directly required for order processing or visible to customers.&lt;/p&gt;&#10;&lt;h3 id="usability-on-the-operator-side"&gt;Usability on the Operator Side&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The shop should have as few dependencies on third-party software as possible and only integrate those modules that are absolutely necessary. Examples:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Payment processing&lt;/li&gt;&#10;&lt;li&gt;Shopping cart&lt;/li&gt;&#10;&lt;li&gt;Customer account / address data&lt;/li&gt;&#10;&lt;li&gt;Inventory&lt;/li&gt;&#10;&lt;li&gt;Order processing (mailer) for buyers&lt;/li&gt;&#10;&lt;li&gt;Invoicing.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;ol start="3"&gt;&#10;&lt;li&gt;The shop should only contain as many dynamically generated elements as absolutely necessary.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="usability-on-the-buyer-side"&gt;Usability on the Buyer Side&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The web shop should feel like current, common e-commerce solutions: nimble, clear, simple.&lt;/li&gt;&#10;&lt;li&gt;No obligation to have a customer account.&lt;/li&gt;&#10;&lt;li&gt;The shop should support common payment methods (debit, credit, PayPal, Apple/Google Pay, etc.). Only local (German-based) payment service providers are acceptable.&lt;/li&gt;&#10;&lt;li&gt;Inventory levels and production times should be displayed transparently for the user.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="data-protection"&gt;Data Protection&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The shop should only collect data necessary for business transactions.&lt;/li&gt;&#10;&lt;li&gt;If technically and legally possible, the shop should be able to operate without a cookie banner.&lt;/li&gt;&#10;&lt;li&gt;Customer accounts should be automatically deleted after two years of non-use (with two weeks&amp;rsquo; notice).&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="look--feel"&gt;Look &amp;amp; Feel&lt;/h3&gt;&#10;&lt;p&gt;Here are some examples of successful, simple shop designs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://store.caddyserver.com/en-eur/collections/all" target="_blank" rel="noopener noreferrer" class="external-link"&gt;caddyshop&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://dev.to/contentful_blog/how-to-build-an-ecommerce-static-site-with-jekyll-contentful-and-commerce-layer-3c9" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Example ecommerce with Jekyll&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.geeksforgeeks.org/e-commerce-website-using-django/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Example ecommerce with Django&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Requirements derived from this:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The web shop&amp;rsquo;s structure and color scheme should match my Blog page and provide a consistent look.&lt;/li&gt;&#10;&lt;li&gt;Only static fonts should be used without reloading.&lt;/li&gt;&#10;&lt;li&gt;Product descriptions should be able to be uploaded as Markdown documents (or similar).&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="database-or-file-solution"&gt;Database or file solution?&lt;/h2&gt;&#10;&lt;p&gt;It doesn&amp;rsquo;t always have to be a database. For many applications, only a fraction of the functionality of common database solutions is required, but this significantly increases the complexity of the overall system. For example, if concurrent writing rarely or never occurs and there is no strong data concatenation, a file solution can be simpler, faster, and more efficient &lt;a href="https://engineeringkiosk.dev/podcast/episode/129-simplify-your-stack-files-statt-datenbanken/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Linking a related &amp;ldquo;Engineering Kiosk&amp;rdquo; podcast episode&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;On the other hand, many shop systems come with batteries included, meaning they already have built-in data management that is almost transparent to the programmers.&lt;/p&gt;&#10;&lt;h3 id="file-solution-static-website"&gt;File solution (static website)&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;From the operator&amp;rsquo;s perspective, the web shop should feel like a Git project (everything is a file).&lt;/li&gt;&#10;&lt;li&gt;Changes to the offer, item descriptions, prices, etc. should be able to be pushed to the server via git push - just like with a static website.&lt;/li&gt;&#10;&lt;li&gt;If a pull request is merged into &amp;lsquo;main&amp;rsquo;, the web shop should be rebuilt (CD pipeline) and the changes published to the server.&lt;/li&gt;&#10;&lt;li&gt;Dynamic elements are required for inventory management, shopping carts, customer interaction, and automated functions. Here, it is important to decide which elements are best handled directly in JavaScript and which are better handled via an automation pipeline.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="database-solution-dynamic-website"&gt;Database solution (dynamic website)&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Products are stored in the database.&lt;/li&gt;&#10;&lt;li&gt;The web shop is managed via a web interface.&lt;/li&gt;&#10;&lt;li&gt;Database management should be partially automated, with backups and encryption for customer data provided.&lt;/li&gt;&#10;&lt;li&gt;The database is transparent for the admin: There is no direct interaction; everything is channeled through e.g. a web interface.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="in-the-next-article"&gt;In the next article&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://blog.schallbert.de/en/ecommerce-alternatives/"&gt;Comparing e-commerce solutions&lt;/a&gt; for my webshop&lt;/p&gt;&#10;</description></item><item><title>PeerTube instance migration</title><link>https://blog.schallbert.de/en/fediverse-ups-and-downs/</link><pubDate>Tue, 01 Apr 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/fediverse-ups-and-downs/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/announcements/2025-03-17-makertube.jpg"&#10; class="post-cover"&#10; alt="Image: My MakerTube channel &amp;#39;&amp;#39;CNC corner&amp;#39;&amp;#39;"&#10; title="PeerTube instance migration" /&gt;&#10;&lt;h2 id="brief-definition"&gt;Brief definition&lt;/h2&gt;&#10;&lt;h3 id="fediverse"&gt;Fediverse?&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://fediverse.info/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Fediverse calls something Fediverse&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; when several providers use the same protocol to allow users of these providers to interact freely with each other on digital media. This creates highly interconnected but decentralized services, where free choice of provider is part of the concept. &lt;a href="https://www.theverge.com/24063290/fediverse-explained-activitypub-social-media-open-protocol" target="_blank" rel="noopener noreferrer" class="external-link"&gt;The Verge&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; goes into detail here for your reference.&lt;/p&gt;&#10;&lt;h3 id="peertube"&gt;PeerTube?&lt;/h3&gt;&#10;&lt;p&gt;PeerTube is a decentralized video platform where multiple servers share the load and viewers can also help distribute videos. Roughly speaking, &lt;em&gt;PeerTube&lt;/em&gt; is to &lt;em&gt;YouTube&lt;/em&gt; what &lt;em&gt;Mastodon&lt;/em&gt; is to &lt;em&gt;X&lt;/em&gt;.&lt;/p&gt;&#10;&lt;h2 id="the-reason"&gt;The Reason&lt;/h2&gt;&#10;&lt;p&gt;This article begins with an email.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;You&amp;rsquo;re receiving this non-automated email because you&amp;rsquo;re one of the handful of people who has a video hosted on this PeerTube instance. I have decided to shut down this service [&amp;hellip;]&amp;rdquo; - Tom, &lt;em&gt;scitech.video&lt;/em&gt; administrator&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;He was really sorry, but continuing to operate the instance would cost too much time, maintenance, and money. Furthermore, the platform is currently underused, so the effort seems disproportionate.&lt;/p&gt;&#10;&lt;p&gt;So far, so understandable.&lt;/p&gt;&#10;&lt;h2 id="toms-story-scitechvideo"&gt;Tom&amp;rsquo;s Story: &lt;em&gt;scitech.video&lt;/em&gt;&lt;/h2&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-04-01-scitechvideo-stats.avif" alt="Image: scitech.video statistics as of Mid-March 2025: ~16k views total"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Since I had uploaded and linked all my videos there, I was very interested in the background. So I asked for more information. My questions were answered comprehensively and promptly.&lt;/p&gt;&#10;&lt;h3 id="the-beginnings"&gt;The Beginnings&lt;/h3&gt;&#10;&lt;p&gt;Tom set up his PeerTube instance in 2019. Originally to share his own content. Shortly thereafter, he opened the hosting service to others, albeit with some unease.&lt;/p&gt;&#10;&lt;p&gt;From an admin perspective, such a video platform seems like the &amp;ldquo;Wild West&amp;rdquo;. Ensuring healthy growth for its offerings has proven quite difficult, especially because of the many bots and advertisers involved.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Growing the service turned out to be challenging. Every time I opened registrations spambots started creating accounts easily and uploading videos with advertisements in the video descriptions [&amp;hellip;]&amp;rdquo; - Tom, &lt;em&gt;scitech.video&lt;/em&gt; administrator, via E-Mail&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;h3 id="lots-of-bots-and-unwanted-users-around"&gt;Lots of bots and unwanted users around&lt;/h3&gt;&#10;&lt;p&gt;While he was always able to prevent these unwanted spammers, scammers, and advertisers from contaminating &lt;em&gt;scitech.video&lt;/em&gt;, this still meant constant work and manual monitoring of the automatic rejection and moderation tools.&lt;/p&gt;&#10;&lt;p&gt;At this point, I&amp;rsquo;d like to agree with Tom. I have an average of about &lt;em&gt;340&lt;/em&gt; failed connections from external IP addresses to my server per day, which I then block. On the ssh port alone. And my server is neither particularly large nor well-known, nor can it be considered a worthwhile target. Not to mention the countless attempts to access my Gitea user interface, crawl the website for hidden links, and much more.&lt;/p&gt;&#10;&lt;p&gt;For operators of a portal that also grants external users write access (videos, descriptions, tags, comments), the problem is even more serious. Unlike my site, participation is part of the concept here.&lt;/p&gt;&#10;&lt;h3 id="copyright"&gt;Copyright?&lt;/h3&gt;&#10;&lt;p&gt;Exactly. From the platform operator&amp;rsquo;s perspective, every video is fundamentally subject to copyright or related rights. But is the user who wants to publish this video also the author? Or has this person acquired usage rights?&lt;/p&gt;&#10;&lt;p&gt;Depending on the country and the associated legal situation, administrators must address this issue very carefully. High penalties can be imposed on both administrators and users if a copyright infringement occurs.&lt;/p&gt;&#10;&lt;p&gt;The former, at least in Germany, can be resolved according to a &lt;a href="https://datenbank.nwb.de/Dokument/1055605/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Federal Court of Justice ruling of October 23, 2024&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, as follows: &amp;ldquo;[&amp;hellip;] after a clear indication of a violation of law, the content posted there must be checked for similar violations within the scope of what is technically and economically reasonable, and infringing content must be blocked or deleted.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;However, due to the authorities&amp;rsquo; strong focus on specific subject areas, violations are usually quickly noticed - because users can also &amp;ldquo;report&amp;rdquo; posts.&lt;/p&gt;&#10;&lt;h3 id="the-legal-situation-for-social-networks"&gt;The legal situation for social networks&lt;/h3&gt;&#10;&lt;p&gt;Some countries are currently preparing laws to protect children and young people from using social networks and the associated dangers. For reference, here is a link to the Federal Agency for Youth and Media Protection. Here, too, the administrators are required to implement these rules - but how?&lt;/p&gt;&#10;&lt;p&gt;At least, this is what Tom is asking himself, given the increasingly diverse legal interpretations of different countries.&lt;/p&gt;&#10;&lt;h3 id="updates-and-maintenance"&gt;Updates and Maintenance&lt;/h3&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;PeerTube upgrades were a bit of a drag because everything was tightly coupled with their specific nginx configuration, with breaking changes that had to be adjusted every couple of minor versions.&amp;rdquo; - Tom, &lt;em&gt;scitech.video&lt;/em&gt; administrator, via email&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The work isn&amp;rsquo;t particularly difficult to perform, but it does take a lot of time. As with other software, it can happen that a PeerTube server won&amp;rsquo;t boot after an operating system update, for example, or that incompatibilities arise between different software versions or modules.&lt;/p&gt;&#10;&lt;p&gt;For performance reasons, testing proves difficult, so work is often carried out directly in &amp;ldquo;Prod.&amp;rdquo; In the event of an error, live operation suffers.&lt;/p&gt;&#10;&lt;p&gt;The configuration is relatively complex and has to be adapted to the hardware. Major PeerTube updates also require changes here.&lt;/p&gt;&#10;&lt;h3 id="conclusion"&gt;Conclusion&lt;/h3&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;I&amp;rsquo;m also disillusioned about the fediverse generally as a communication medium. A constant battle against spam/LLMs, so much anger, so much focus on follows, likes, and boosts.&amp;rdquo; - Tom, &lt;em&gt;scitech.video&lt;/em&gt; administrator, via email&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Tom is frustrated that the attention economy is taking hold here as well. Although conceived as ad-free and rolled out as a small grassroots movement, instances created out of idealism are quickly misused for advertising and self-promotion. The costs also amount to several hundred dollars a year, which further worsens the cost-benefit ratio.&lt;/p&gt;&#10;&lt;p&gt;One solution here, according to Tom, could be &amp;ldquo;a return to Web 1.0 or closed communities,&amp;rdquo; as already exists on &lt;em&gt;Meatspace&lt;/em&gt;, &lt;em&gt;Matrix&lt;/em&gt;, &lt;em&gt;Discord&lt;/em&gt;, etc.&lt;/p&gt;&#10;&lt;p&gt;All the other factors also make it difficult to continue operating his instance: The user base never grew particularly large, and there are only a few people who still upload their content there. As a father, he now has much less time available and must prioritize projects. Thus, he is now ending his six-year journey as a provider on the Fediverse.&lt;/p&gt;&#10;&lt;p&gt;I wish Tom continued strength, idealism, and enthusiasm for the future.&lt;/p&gt;&#10;&lt;h2 id="jans-story-makertubenet"&gt;Jan&amp;rsquo;s story: &lt;em&gt;makertube.net&lt;/em&gt;&lt;/h2&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-04-01-makertube-stats.avif" alt="Image: MakerTube statistics as of Mid March-2025: ~120k views total"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Since I now needed to look for a new hosting platform for my videos, I tried searching for &amp;ldquo;video-maker&amp;rdquo; on the &lt;a href="https://joinpeertube.org/instances?profile=video-maker" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PeerTube website&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Since my content has a clear tech focus and ranges from research and product development to pre-production, it wasn&amp;rsquo;t difficult to quickly find suitable candidates.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s how I came to my channel on &lt;em&gt;MakerTube&lt;/em&gt;.&lt;/p&gt;&#10;&lt;p&gt;After successfully registering, I wrote an email to the admins with similar questions to the ones I had already asked Tom.&lt;/p&gt;&#10;&lt;h3 id="motivation---why-makertube"&gt;Motivation - why MakerTube?&lt;/h3&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;I have a small YouTube channel, and the constant advertising and focus on the big channels, clickbait, etc., and how people are forced to adapt to the content machine, was getting on my nerves. [&amp;hellip;]&amp;rdquo; - Jan, &lt;em&gt;MakerTube&lt;/em&gt; administrator, via email&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Jan was already active on the Fediverse and also works professionally with web services that need to scale rapidly. Therefore, it seemed logical to him to set up his own instance - but of course, with the really interesting topics of this world. It&amp;rsquo;s not intended as a competitor to &lt;em&gt;YouTube&lt;/em&gt; and therefore focuses on makers, musicians, artists, etc.&lt;/p&gt;&#10;&lt;h3 id="is-it-easy-to-get-started"&gt;Is it easy to get started?&lt;/h3&gt;&#10;&lt;p&gt;Jan mentions it takes a lot of effort to set up such an instance and keep it performing well. &lt;a href="https://framasoft.org/en/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Framasoft&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, who develop the PeerTube software, have done a fantastic job and support the hosting community wherever they can.&lt;/p&gt;&#10;&lt;p&gt;I can personally confirm that PeerTube feels completely different in its newer versions than it did a few years ago, and with practical features like automatically generated subtitles, it&amp;rsquo;s getting closer and closer to the functionality of &lt;em&gt;YouTube&lt;/em&gt;.&lt;/p&gt;&#10;&lt;p&gt;Jan says that the Fediverse remains a niche. Therefore, the growth of his instance is rather moderate and very organic. &lt;em&gt;PeerTube&lt;/em&gt; is currently still something for enthusiastic video creators; the content largely goes unnoticed by the general public.&lt;/p&gt;&#10;&lt;h3 id="how-much-time-does-it-take"&gt;How much time does it take?&lt;/h3&gt;&#10;&lt;p&gt;Jan focused on low running costs while maintaining scalability in case the project suddenly becomes large. Now that the system is running smoothly, the effort is a few hours per week for user, content, and support moderation.&lt;/p&gt;&#10;&lt;p&gt;Exceptions: Major updates, upgrades, and technical issues.&lt;/p&gt;&#10;&lt;h3 id="what-are-people-saying"&gt;What are people saying?&lt;/h3&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;There&amp;rsquo;s positive feedback, and people are grateful that I run the site and put in the work, but I&amp;rsquo;d say not very much.&amp;rdquo; - Jan, &lt;em&gt;MakerTube&lt;/em&gt; administrator, via email&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;But of course, there&amp;rsquo;s also occasional outrage that this isn&amp;rsquo;t &lt;em&gt;YouTube&lt;/em&gt; and not everyone can immediately create an account.&lt;/p&gt;&#10;&lt;h3 id="how-do-you-detect-copyright-infringements"&gt;How do you detect copyright infringements?&lt;/h3&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Regarding copyright, there are no direct tools. There&amp;rsquo;s a report function on the videos to point out violations of copyright or instance rules. Users also use this.&amp;rdquo; - Jan, &lt;em&gt;MakerTube&lt;/em&gt; administrator, via email&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Responsibility and liability for small providers like &lt;em&gt;MakerTube&lt;/em&gt; lie primarily with the user. However, if violations are reported, the admins must take action and delete content. On &lt;em&gt;MakerTube&lt;/em&gt;, however, this isn&amp;rsquo;t a problem in everyday life: New user registrations are carefully reviewed, and in cases of doubt, the content is inquired about. Most violations concern background music in videos, so there seems to be less awareness of copyright than with uploads of video copies.&lt;/p&gt;&#10;&lt;h3 id="and-how-is-it-all-financed"&gt;And how is it all financed?&lt;/h3&gt;&#10;&lt;p&gt;&lt;em&gt;MakerTube&lt;/em&gt; is financed through &lt;a href="https://makertube.net/about/instance/support" target="_blank" rel="noopener noreferrer" class="external-link"&gt;donations&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. According to Jan, especially in the beginning, a lot of time was spent explaining this to people and securing sufficient budget for smooth operation. A video platform is resource-intensive: lots of CPU power for transcoding and streaming, a ton of disk space for all the assets, and simultaneously high bandwidth requirements, especially during peak times.&lt;/p&gt;&#10;&lt;h3 id="conclusion-1"&gt;Conclusion&lt;/h3&gt;&#10;&lt;p&gt;I&amp;rsquo;m satisfied with &lt;em&gt;MakerTube&lt;/em&gt; so far: Transcoding is fast, videos run smoothly. Even &lt;em&gt;Heise-Verlag&lt;/em&gt; has now uploaded their first video to the instance. However, it&amp;rsquo;s much more difficult to get to the landing page without a &amp;ldquo;fan base,&amp;rdquo; which was never a problem for me with &lt;em&gt;scitech.video&lt;/em&gt; 😅.&lt;/p&gt;&#10;&lt;p&gt;Let me close with another quote from the site itself:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;MakerTube is here to stay.&amp;rdquo; - &lt;em&gt;MakerTube&lt;/em&gt;, March 2025, about &lt;em&gt;MakerTube&lt;/em&gt;.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Thanks, Jan. Here&amp;rsquo;s to the future!&lt;/p&gt;&#10;</description></item><item><title>Gitea: stop search indexer</title><link>https://blog.schallbert.de/en/gitea-search-indexation/</link><pubDate>Wed, 12 Mar 2025</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/gitea-search-indexation/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-03-12-gitea-indexation-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: How gitea recommends to remove search indexation"&#10; title="Gitea: stop search indexer" /&gt;&#10;&lt;p&gt;After ages, I took a trip to the Google Search Console just for fun. Normally, I&amp;rsquo;m not interested in it because I don&amp;rsquo;t use Google myself and assumed that the search engines would do their job well out of self-interest.&lt;/p&gt;&#10;&lt;p&gt;I was confused when I realized that the majority of my pages didn&amp;rsquo;t even make it through the indexer.&lt;/p&gt;&#10;&lt;h2 id="the-problem"&gt;The problem&lt;/h2&gt;&#10;&lt;p&gt;How are people supposed to find my website? Even if they knew the correct search terms, they would hardly see anything on Google. After all, the console clearly stated that none of my blog posts appeared in the indexer. On my Gitea instance, on the other hand, several thousand pages were listed. A clear disproportion. And why does Gitea generate such a high volume of files?&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-03-12-searchconsole.avif" alt="Image: Google Search console view for git.schallbert.de. Thousands of pages are crawled that a real user will never be interested in."&gt;&lt;/figure&gt;&#10;&lt;p&gt;Google&amp;rsquo;s &lt;a href="https://en.wikipedia.org/wiki/Web_crawler" target="_blank" rel="noopener noreferrer" class="external-link"&gt;crawler&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; (a program that searches the web for content and makes it indexable for search) apparently finds every commit, no matter how small, in public repositories as well as the files behind them. Automation runs and other metadata are also indexed.&lt;/p&gt;&#10;&lt;p&gt;This is of course completely unnecessary and wastes energy in an area that I would much rather have in the presentation of my blog. So a solution is needed.&lt;/p&gt;&#10;&lt;h2 id="possible-solutions"&gt;Possible solutions&lt;/h2&gt;&#10;&lt;p&gt;A few possibilities immediately occurred to me:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Configure the crawler for each repository so that only the top folder level is searched.&lt;/li&gt;&#10;&lt;li&gt;Completely switch off search indexing for &lt;code&gt;git.schallbert.de&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Use Search Console to make corrections until the indexer shows correct assignments on my blog posts.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The most obvious solution for me was to completely switch off the indexer for my Gitea instance. Correction loops only take effect days or weeks later with Google&amp;rsquo;s crawler and turned out too time-consuming. I deemed a separate &lt;code&gt;robots.txt&lt;/code&gt; for each repository too complicated.&lt;/p&gt;&#10;&lt;h2 id="my-implementation"&gt;My implementation&lt;/h2&gt;&#10;&lt;p&gt;I read the &lt;a href="https://docs.gitea.com/administration/search-engines-indexation" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The suggestion of a &lt;code&gt;robots.txt&lt;/code&gt; made sense to me immediately, as did its contents:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;User-agent&lt;/span&gt;: *&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;Disallow&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This tells crawlers from any party &lt;code&gt;*&lt;/code&gt; that nothing should be indexed from the root directory &lt;code&gt;/&lt;/code&gt; onwards.&lt;/p&gt;&#10;&lt;p&gt;Unfortunately, I had no idea how Gitea makes this file available in its instance:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;To make Gitea serve a custom robots.txt (default: empty 404) for top level installations, create a file with path &lt;code&gt;public/robots.txt&lt;/code&gt; in the &lt;a href="https://docs.gitea.com/administration/customizing-gitea" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;custom&lt;/code&gt; folder or &lt;code&gt;CustomPath&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;h3 id="giteas-configuration-directory"&gt;Gitea&amp;rsquo;s configuration directory&lt;/h3&gt;&#10;&lt;p&gt;So I experimented a bit. I created a &lt;code&gt;public&lt;/code&gt; folder with the corresponding file, then a &lt;code&gt;custom&lt;/code&gt; folder at different levels within the Gitea folder structure. Each time I restarted the container and checked whether the robots file also appeared on the server.&lt;/p&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-03-12-whererobots.avif" alt="Image: Where I put robots.txt in Gitea&amp;#39;s folder tree"&gt;&lt;/figure&gt;&#10;Nothing.&lt;/p&gt;&#10;&lt;p&gt;But then I got rid of the folder names and simply put the file in the Gitea configuration directory - the lowest level at which &lt;em&gt;gitea&lt;/em&gt; no longer appears as a folder name. And this was the solution.&lt;/p&gt;&#10;&lt;h3 id="test"&gt;Test&lt;/h3&gt;&#10;&lt;p&gt;After restarting, I was able to successfully display the file in the browser.&lt;/p&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--left"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2025-03-12-robotsolution.avif" alt="Image: Browser successfully loaded my robots.txt for the Gitea instance"&gt;&lt;/figure&gt;&#10;In the meantime, Google is also starting to remove the unwanted pages from the indexer. My blog articles will hopefully also be available in Google search over the next few weeks.&lt;/p&gt;&#10;</description></item><item><title>Repo-Lookout: Fix repository leaks</title><link>https://blog.schallbert.de/en/repo-lookout-fix-deploy/</link><pubDate>Fri, 20 Dec 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/repo-lookout-fix-deploy/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-12-20-repolookout-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Repolookout reporting a possible vulnerability on my site"&#10; title="Repo-Lookout: Fix repository leaks" /&gt;&#10;&lt;h2 id="repo-information-publicly-available"&gt;Repo information publicly available&lt;/h2&gt;&#10;&lt;p&gt;One fine day I got an email from &lt;a href="https://www.repo-lookout.org/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Repo Lookout&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. It said that one of my repositories was open to access from the internet. This posed a potential security risk as it might contain secret source files, hidden functions or even passwords.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-12-20_repolookout_warning.jpg" alt="Image: RepoLookout mail notification"&gt;&lt;/figure&gt;&#10;&lt;p&gt;At first I thought this &amp;ldquo;your repo is not secure&amp;rdquo; warning was a phishing attempt.&#10;But by simply entering the links contained therein, it became clear that &lt;em&gt;Repo Lookout&lt;/em&gt; was right and that my &lt;a href="https://lectures.schallbert.de" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Lectures&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; repo was not only public as I had wanted, but that the version control metadata was also openly available on the web server.&lt;/p&gt;&#10;&lt;h3 id="is-that-bad"&gt;Is that bad?&lt;/h3&gt;&#10;&lt;p&gt;Normally, the internal structure and configuration (actions, discussions, wiki, etc.) behind a repository should not be public. Especially not if the repository is set up as &lt;code&gt;private&lt;/code&gt;. But even with public repos, no one should be able to access the structure behind them.&lt;/p&gt;&#10;&lt;p&gt;Hence the mission of Repo-Lookout:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Find source code repositories that have been inadvertently exposed to the public and report them to the domain&amp;rsquo;s technical contact.&amp;rdquo; - Repo Lookout /about (&lt;a href="https://www.crissyfield.de/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Crissy Field GmbH&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;)&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;In this case, not critical, but unexpected and unpleasant.&lt;/p&gt;&#10;&lt;p&gt;Doesn&amp;rsquo;t do me any harm, because my web server only has the files there for retrieval and even if they were tampered with, they would have had no effect on my repository. I also had all secrets stored in specially created files as described &lt;a href="https://blog.schallbert.de/en/server-config-version-control/#secrets-in-docker-composeyml"&gt;in this article&lt;/a&gt;, so that they no longer appear in the configuration files. The repository is also located separately on the &lt;a href="https://git.schallbert.de/schallbert/lectures" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea server instance&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Nevertheless, only what I consciously want to make public should be connected to the Internet.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-12-20_repo_commithistory.jpg" alt="Image: The repository&amp;#39;s commit history is public anyways"&gt;&lt;/figure&gt;&#10;&lt;p&gt;The image section from &lt;em&gt;Gitea&lt;/em&gt; shows the same commit as the warning from &lt;em&gt;Repo Lookout&lt;/em&gt;.&lt;/p&gt;&#10;&lt;h3 id="how-it-came-about"&gt;How it came about&lt;/h3&gt;&#10;&lt;p&gt;In the deploy pipeline for my subdomain &lt;code&gt;lectures.schallbert.de&lt;/code&gt; and landing page &lt;code&gt;schallbert.de&lt;/code&gt; I have a direct checkout from the &lt;em&gt;Gitea&lt;/em&gt; instance to the web server &lt;em&gt;Caddy&lt;/em&gt;. This starts automatically as soon as the &lt;code&gt;main&lt;/code&gt; branches receive an update. The runner starts a &lt;a href="https://github.com/marketplace/actions/checkout" target="_blank" rel="noopener noreferrer" class="external-link"&gt;checkout action&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, which copies the repository to the corresponding directory on the web server.&lt;/p&gt;&#10;&lt;h3 id="checkout-action-also-copies-the-git-folder"&gt;Checkout action also copies the &lt;code&gt;.git&lt;/code&gt; folder&lt;/h3&gt;&#10;&lt;p&gt;The &lt;code&gt;.git&lt;/code&gt; folder is simply set up as well. This is where all the data required by the version control software &lt;a href="https://git-scm.com/docs/gitrepository-layout" target="_blank" rel="noopener noreferrer" class="external-link"&gt;for state management&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; of the repo is stored. I cannot see the copying process itself on &lt;em&gt;Gitea&lt;/em&gt;, because the hidden &lt;code&gt;.git&lt;/code&gt; folder does not even appear in the directory there. Understandable, because the entire display on &lt;em&gt;Gitea&lt;/em&gt; is based on this folder&amp;rsquo;s content.&lt;/p&gt;&#10;&lt;p&gt;So the unwanted behavior remained under my radar - and according to &lt;em&gt;Repo Lookout&lt;/em&gt; I am by no means the only one who has this problem.&lt;/p&gt;&#10;&lt;h2 id="option-1-fix-on-the-web-server"&gt;Option 1: Fix on the web server&lt;/h2&gt;&#10;&lt;p&gt;The most obvious solution is to block access to the file on the server side. This costs few resources and is easy to set up.&lt;/p&gt;&#10;&lt;p&gt;This forum entry shows how to do it: &lt;a href="https://caddy.community/t/v2-hide-entire-folder-caddyfile/7234" target="_blank" rel="noopener noreferrer" class="external-link"&gt;hide-entire-folder-caddyfile&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. For the files I want to protect, I add the following entries to the &lt;code&gt;Caddyfile&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /caddy2/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;respond /.git/* &amp;#34;Access denied&amp;#34; 403&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;respond /.gitea/* &amp;#34;Access denied&amp;#34; 403&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This tells Caddy that when any file &lt;code&gt;/*&lt;/code&gt; in the folder &lt;code&gt;/.git&lt;/code&gt; is called, it should respond with the error code &lt;code&gt;403&lt;/code&gt; &amp;ldquo;Forbidden&amp;rdquo;. The wildcard (&lt;code&gt;*&lt;/code&gt;) is absolutely necessary, otherwise &lt;em&gt;only the folder itself&lt;/em&gt; and not the files it contains will be locked.&lt;/p&gt;&#10;&lt;p&gt;To check, I check what happens when I request the Git logs:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;# Terminal&#10;curl &amp;lt;lectures.schallbert.de&amp;gt;/.git/logs/HEAD&#10;Access denied &#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Works as designed.&lt;/p&gt;&#10;&lt;h2 id="option-2-fix-in-the-checkout-action"&gt;Option 2: Fix in the checkout action&lt;/h2&gt;&#10;&lt;p&gt;There is, however, a more elegant solution: ensure beforehand in the pipeline that the folder does not appear on the server at all.&lt;/p&gt;&#10;&lt;h3 id="option-1-using-sparse-checkout"&gt;Option 1: Using &lt;code&gt;sparse-checkout&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://stackoverflow.com/questions/33933702/git-checkout-except-one-folder" target="_blank" rel="noopener noreferrer" class="external-link"&gt;sparse-checkout&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; allows you to select folders and files that should be part of the checkout. All other files in the repository remain untouched and do not appear in the branch. This saves time and storage space, especially with large repositories. But of course it only makes sense if you already know in advance that not all files need to be touched.&lt;/p&gt;&#10;&lt;h3 id="negative-list-for-sparse-checkout"&gt;Negative list for &lt;code&gt;sparse-checkout&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;In my case, I don&amp;rsquo;t want to copy the folders mentioned above to the server using checkout, but I want to copy everything else. How do I do that? Using negation in &lt;code&gt;no-cone&lt;/code&gt; mode.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;The user has explicitly said &amp;lsquo;I want these directories and not those directories.&amp;rsquo;&amp;rdquo; - Derrick Stolee, Microsoft, on &lt;a href="https://github.com/git/git/commit/55dfcf9591b088ce60ec80eb5425dda18223cac0" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The &lt;a href="https://github.com/marketplace/actions/checkout" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Checkout Action Guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; states that &lt;code&gt;sparse-checkout&lt;/code&gt; is also supported for the action automated by the runner.&lt;/p&gt;&#10;&lt;p&gt;Now I program with the help of the &lt;a href="https://github.github.com/actions-cheat-sheet/actions-cheat-sheet.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github Actions Cheet Sheet&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /.gitea/workflows/deploy-lectures.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;steps&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;CHECKOUT ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/checkout@v4&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;with&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;path&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;./tmp&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;sparse-checkout&lt;/span&gt;: |&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; /*&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; !.git&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; !.gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;sparse-checkout-cone-mode&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To clarify: The script for publishing to my web server uses the action &lt;code&gt;checkout&lt;/code&gt;, subfunction &lt;code&gt;sparse-checkout&lt;/code&gt; and includes all files in the folder in the root directory &lt;code&gt;tmp&lt;/code&gt; and below except &lt;code&gt;.git&lt;/code&gt; and &lt;code&gt;.gitea&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="what-is-no-cone-mode"&gt;What is &lt;code&gt;no-cone mode&lt;/code&gt;?&lt;/h3&gt;&#10;&lt;p&gt;By default, &lt;code&gt;sparse-checkout&lt;/code&gt; expects a list of folders to include for checkout. In &lt;code&gt;no-cone&lt;/code&gt; mode, a list of patterns is expected instead. All operators that can also be used in the &lt;code&gt;.gitignore&lt;/code&gt; to specify files, folders, omissions, etc. are possible here. This allows me to exclude certain folders, but has some &lt;a href="https://git-scm.com/docs/git-sparse-checkout#_internalsnon_cone_problems" target="_blank" rel="noopener noreferrer" class="external-link"&gt;significant disadvantages&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Due to the much higher complexity of the pattern commands, the associated susceptibility to errors and the significantly more computationally intensive evaluation for larger repositories, the use of the &lt;code&gt;no-cone&lt;/code&gt; mode is not recommended and is listed as &amp;ldquo;deprecated&amp;rdquo; in the documentation. Nevertheless, the proof is in the pudding!&lt;/p&gt;&#10;&lt;h3 id="test-with-sparse-checkout"&gt;Test with &lt;code&gt;sparse-checkout&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;Now I upload the action to my Gitea instance and let my &lt;em&gt;runner&lt;/em&gt; run it once. Then I log in to the web server and check whether the &lt;code&gt;.git&lt;/code&gt; folder was created or not:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;lectures# ls -la&#10;[...]&#10;drwxr-xr-x 8 root root 4096 Dec 20 10:41 .git&#10;[...]&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Damn, the folder is still there. I look in the logs of the action on my Gitea instance:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;[...]&#10;hint: &#9;git branch -m &amp;lt;name&amp;gt;&#10;Initialized empty Git repository in /workspace/schallbert/lectures/tmp/.git/&#10;[...]&#10;::group::Setting up sparse checkout&#10;[command]/usr/bin/git config core.sparseCheckout true&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So it&amp;rsquo;s not because of &lt;code&gt;sparse-checkout&lt;/code&gt;. It&amp;rsquo;s because of the way &lt;em&gt;checkout&lt;/em&gt; works: Obviously, the &lt;code&gt;.git&lt;/code&gt; folder is absolutely necessary for setting up the repository properly on my web server. So the only option I have is to delete it automatically after checking out.&lt;/p&gt;&#10;&lt;h3 id="option-2-rm--rf"&gt;Option 2: &lt;code&gt;rm -rf&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;And so I try it by force:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /.gitea/workflows/deploy-lectures.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;steps&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;CHECKOUT ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/checkout@v4&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;with&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;path&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;./tmp&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;REMOVE TEMPORARY FILES ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: |&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; rm -rfv ./tmp/.git ./tmp/.gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And finally the &lt;code&gt;.git&lt;/code&gt; folder no longer appears on my web server and my &amp;ldquo;repo leak&amp;rdquo; is patched. Thanks again to &lt;em&gt;Repo Lookout&lt;/em&gt;!&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;I had the problem that the hidden &lt;code&gt;.git&lt;/code&gt; folder, where the configuration and structure of repositories are stored, was published on my web server unintentionally and without my knowledge.&lt;/p&gt;&#10;&lt;p&gt;I have presented two working options for solving the problem here:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Set up an access ban on the web server&lt;/li&gt;&#10;&lt;li&gt;Modify the pipeline so that it automatically deletes the &lt;code&gt;.git&lt;/code&gt; folder after it has been rolled out.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The second option is a bit more complex to implement, but it gets to the root of the problem instead of just fixing the symptoms. It also corresponds to the first principle of data protection: data minimization takes precedence over protective measures.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;What doesn&amp;rsquo;t exist cannot be lost&amp;rdquo; - Schallbert&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;</description></item><item><title>Logrotate-Recursion</title><link>https://blog.schallbert.de/en/logrotate-mistake/</link><pubDate>Mon, 18 Nov 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/logrotate-mistake/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-11-18_logrotate-recursion.jpg"&#10; class="post-cover"&#10; alt="Image: Logrotate creating .1 recursively"&#10; title="Logrotate-Recursion" /&gt;&#10;&lt;h2 id="what-isnt-working"&gt;What isn&amp;rsquo;t working?&lt;/h2&gt;&#10;&lt;p&gt;I actually wanted to use &lt;a href="https://github.com/logrotate/logrotate" target="_blank" rel="noopener noreferrer" class="external-link"&gt;logrotate&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to clean up my logs daily and thus implement data economy: This way, IP addresses that I need for &lt;a href="https://blog.schallbert.de/en/server-protection/"&gt;banning&lt;/a&gt; can be deleted uniformly and automatically after a short time. Otherwise, I&amp;rsquo;m not interested in the server logs at all and they just steal storage space.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-11-18_logrotate-recursion.jpg" alt="Image: The way logrotate does the rotation looks all too recursive."&gt;&lt;/figure&gt;&#10;&lt;p&gt;With my current configuration, &lt;code&gt;logrotate&lt;/code&gt; creates one log file per day, but names it &lt;code&gt;xyz.log.1, xyz.log.1.1, xyz.log.1.1.1&lt;/code&gt; instead of the expected &lt;code&gt;xyz.log.1, xyz.log.2, xyz.log.3&lt;/code&gt;. This means that the deletion routine no longer works and the number of logs keeps growing.&lt;/p&gt;&#10;&lt;h2 id="and-why"&gt;And why?&lt;/h2&gt;&#10;&lt;p&gt;I suspect that my &lt;em&gt;logrotate&lt;/em&gt; configuration is faulty. At the moment it looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;## LOGROTATE file named &amp;#39;blog&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;blog/* {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# it&amp;#39;s ok if the file doesn&amp;#39;t already exist&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;missingok&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Sets the logs to rotate in intervals&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;daily&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Tells the system to remove old logs and only keep the most recent rotated logs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;rotate 7&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Rotated logs will be compressed and kept on disk if they are 10 MB or less.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;size 10M&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# compress and delaycompress: These two options are used together and indicate that &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# rotated logs should be compressed (gzip) except for the most recent one.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;compress&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;delaycompress&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As usual, the first thing I do is consult the manual, which I call up in the console using &lt;code&gt;man logrotate&lt;/code&gt;.&#10;It says that the file to be rotated can be specified directly (and not just selected using a wildcard &lt;code&gt;*&lt;/code&gt;). In addition, a log behavior can also be applied to multiple file paths by placing them in front of the curly bracket, separated by spaces.&lt;/p&gt;&#10;&lt;p&gt;The asterisk seems to be causing exactly my problem with the cascading logs: This not only rotates the actual target file &lt;code&gt;access.log&lt;/code&gt;, but also touches all the files that have already been rotated again.&lt;/p&gt;&#10;&lt;h2 id="the-solution"&gt;The solution&lt;/h2&gt;&#10;&lt;p&gt;According to the manual, I rewrite the processing instructions for Logrotate so that instead of five separate instructions for the respective logs, I only create two files. These are also simpler and shorter than the original version above.&lt;/p&gt;&#10;&lt;p&gt;Example:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# rotate webserver log files.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;gitea/access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;blog/access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;landing/access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;lectures/access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Sets the logs to rotate in intervals&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;daily&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Tells the system to remove old logs and only keep the most recent rotated logs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;rotate 7&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# compress and delaycompress: These two options are used together and indicate that &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# rotated logs should be compressed (gzip) except for the most recent one.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;compress&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;delaycompress&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="configuration-update"&gt;Configuration update&lt;/h3&gt;&#10;&lt;p&gt;I can roll this out using my existing server configuration pipeline by simply pushing it into the repository on the server.&#10;Now I just have to manually copy the files there to &lt;code&gt;/etc/logrotate.d&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="testing-logrotate"&gt;Testing Logrotate&lt;/h3&gt;&#10;&lt;p&gt;Now it would be nice if I could test whether the changes I made actually work. To do this, I use the &lt;em&gt;logrotate&lt;/em&gt; user manual again and type:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;logrotate -d -v &amp;lt;LOGROTATE-DESCRIPTOR-FILE&amp;gt;&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;With the &lt;em&gt;debug&lt;/em&gt; and &lt;em&gt;verbose&lt;/em&gt; flags activated, logrotate will apply the rotation rule specified in the configuration file and provide feedback, but will not actually make any changes or rotations on the file system. For me, the result of the test looks something like this:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;lectures/access.log &#10; after 1 days (7 rotations)&#10;empty log files are rotated, old logs are removed&#10;considering log gitea/access.log&#10; Now: 2024-11-28 17:19&#10; Last rotated at 2024-11-27 00:00&#10; log needs rotating&#10;considering log blog/access.log&#10; Now: 2024-11-28 17:19&#10; Last rotated at 2024-11-16 00:00&#10; log needs rotating&#10;considering log landing/access.log&#10; Now: 2024-11-28 17:19&#10; Last rotated at 2024-11-07 00:00&#10; log needs rotating&#10;considering log lectures/access.log&#10; Now: 2024-11-28 17:19&#10; Last rotated at 2024-09-21 00:00&#10; log needs rotating&#10;rotating log gitea/access.log, log-&amp;gt;rotateCount is 7&#10;[...]&#10;renaming lectures/access.log.7.gz to lectures/access.log.8.gz (rotatecount 7, logstart 1, i 7), &#10;renaming lectures/access.log.6.gz to lectures/access.log.7.gz (rotatecount 7, logstart 1, i 6), &#10;renaming lectures/access.log.5.gz to lectures/access.log.6.gz (rotatecount 7, logstart 1, i 5), &#10;renaming lectures/access.log.4.gz to lectures/access.log.5.gz (rotatecount 7, logstart 1, i 4), &#10;renaming lectures/access.log.3.gz to lectures/access.log.4.gz (rotatecount 7, logstart 1, i 3), &#10;renaming lectures/access.log.2.gz to lectures/access.log.3.gz (rotatecount 7, logstart 1, i 2), &#10;renaming lectures/access.log.1.gz to lectures/access.log.2.gz (rotatecount 7, logstart 1, i 1), &#10;renaming lectures/access.log.0.gz to lectures/access.log.1.gz (rotatecount 7, logstart 1, i 0)&#10;[...]&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The output shows that all the desired log files are taken into account by &lt;em&gt;logrotate&lt;/em&gt; and also that the rotation is carried out correctly on a daily basis.&lt;/p&gt;&#10;&lt;p&gt;Learned something new again 😃&lt;/p&gt;&#10;</description></item><item><title>Broken NVME hard disk?</title><link>https://blog.schallbert.de/en/broken-nvme/</link><pubDate>Tue, 10 Sep 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/broken-nvme/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-09-10_nvme-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: My NVME-SSD that suddenly broke down"&#10; title="Broken NVME hard disk?" /&gt;&#10;&lt;h2 id="the-event"&gt;The event&lt;/h2&gt;&#10;&lt;p&gt;Mid-April 2024:&lt;/p&gt;&#10;&lt;p&gt;After a system update, my laptop suddenly wouldn&amp;rsquo;t boot. The bootloader &lt;em&gt;grub-core&lt;/em&gt; reports that it is having problems writing sectors.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;error: ../grub-core/efi/efidisk.c:638: failure writing sector 0xXXXXXXXX&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;When I reboot, the screen remains black after the company logo is displayed with a blinking cursor. If I look behind the mask, loading various services fails:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center media-frame--video"&gt;&#10; &lt;div class="media-video"&gt;&lt;video controls&gt;&#10; &lt;source src="https://blog.schallbert.de/assets/video/posts/2024-09-10_nvme-boot-errors.mp4" type="video/mp4"&gt;&#10; Your browser does not support the video tag.&#10; &lt;/video&gt;&lt;/div&gt;&#10; &lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;Boot errors due to read-only nvme&lt;/span&gt;&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;h2 id="initial-analysis"&gt;Initial analysis&lt;/h2&gt;&#10;&lt;p&gt;I suspected a problem with the computer update and carried out a hard drive test from Lenovo&amp;rsquo;s &lt;a href="https://en.wikipedia.org/wiki/UEFI" target="_blank" rel="noopener noreferrer" class="external-link"&gt;UEFI&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Result:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;The media check is complete, the result is: FAIL&#10;&lt;/code&gt;&lt;/pre&gt;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-09-10_diskcheck-fail.jpg" alt="Image: disk check tool diagnoses a defective device"&gt;&lt;/figure&gt;&#10;&lt;p&gt;It&amp;rsquo;s not a problem with the operating system, but rather with the disk. Damn. Do I have to worry about my data? I do have backups lying around, but the last one is three weeks old, so I might be missing several hours of work. A setup from blank will take me a lot of time. So I&amp;rsquo;m going to try to see if anything can be salvaged.&lt;/p&gt;&#10;&lt;h3 id="self-test"&gt;Self-test&lt;/h3&gt;&#10;&lt;p&gt;So I run the &lt;a href="https://en.wikipedia.org/wiki/Self-Monitoring,_Analysis_and_Reporting_Technology" target="_blank" rel="noopener noreferrer" class="external-link"&gt;SMART&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; tests for the disk with &lt;code&gt;smartctl -x /dev/nvm0&lt;/code&gt; and now know that it counts about &lt;code&gt;7600&lt;/code&gt; hours of operation and has &lt;code&gt;240GB&lt;/code&gt; of usable capacity. The disk used under the name &lt;code&gt;nvme0n1&lt;/code&gt; thinks it is now &amp;ldquo;read-only&amp;rdquo;. All partitions including the kernel. Great. The result of my online search was something like:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;The disk is irreparably damaged. A new one is needed.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;h3 id="repair-attempt"&gt;Repair attempt&lt;/h3&gt;&#10;&lt;p&gt;You don&amp;rsquo;t have to believe everything you read online. So I use Lenovo&amp;rsquo;s own repair tool for hard drives. It runs from the UEFI screen and is called &lt;a href="https://download.lenovo.com/pccbbs/thinkvantage_en/userguide_uefi_v04.08.000.pdf" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Bad Block Recovery Tool&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The program scans the disk for defective sectors and attempts to restore affected blocks. The run takes almost half an hour for the last 10%.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-09-10_badblockrecovery_summary.jpg" alt="Image: Bad Block Recovery tool&amp;#39;s summary for my disk"&gt;&lt;/figure&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-09-10_badblockrecovery_detail.jpg" alt="Image: Bad Block Recovery tool&amp;#39;s output messages"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Result: Recovery failed. I run the tool again. And a third time in slow &lt;code&gt;pedantic&lt;/code&gt; mode. Again and again to no avail. I keep getting &lt;code&gt;critical medium error&lt;/code&gt;.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-09-10_badblockrecovery_log.jpg" alt="Image: Bad Block Recovery tool&amp;#39;s log"&gt;&lt;/figure&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Is all of this not working because the disk has switched to read-only mode?&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;As I can no longer boot and have no console available, my options with on-board resources are now exhausted.&lt;/p&gt;&#10;&lt;h3 id="comment-the-read-only-mode-for-ssds"&gt;Comment: The read-only mode for SSDs&lt;/h3&gt;&#10;&lt;p&gt;According to the Internet &lt;a href="https://www.disktuna.com/a-write-protected-ssd-nvme-read-only/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;(DiskTuna)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, the &lt;em&gt;read only mode&lt;/em&gt; is there to limit damage. If the controller of the storage device detects a serious error, it puts the disk into read-only access. This minimizes the risk of further blocks being corrupted or killed.&lt;/p&gt;&#10;&lt;p&gt;In the forum &lt;a href="https://superuser.com/questions/1802470/ssd-in-read-only-mode-after-power-failure" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Superuser&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; someone describes an error identical to mine, and there is actually nothing that can be done to keep the medium working.&lt;/p&gt;&#10;&lt;h2 id="data-recovery-using-a-boot-stick"&gt;Data recovery using a boot stick&lt;/h2&gt;&#10;&lt;p&gt;I continue with a USB stick that I convert to a Linux boot stick at another computer. I plug it into my laptop and can finally boot it up again. I connect an external hard drive as data recovery medium.&lt;/p&gt;&#10;&lt;h3 id="get-an-overview-with-fdisk"&gt;Get an overview with &lt;em&gt;fdisk&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;First I check whether all disks and partitions appear. To do this I type the command &lt;code&gt;sudo fdisk -l&lt;/code&gt; into the console.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-09-10_fdisk.jpg" alt="Image: fdisk&amp;#39;s output on my live system"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Luckily, everything is still there! Maybe I can just copy the files from the defective hard drive?&lt;/p&gt;&#10;&lt;h3 id="copying-with-cp"&gt;Copying with &lt;em&gt;cp&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;Let&amp;rsquo;s try it out.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sudo cp -r -v /mnt/nvme0n1p3/Documents/my/important/files /mnt/externalHdd/save&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For a few seconds I think everything is going well. But then &lt;code&gt;cp&lt;/code&gt; stops with an error message saying the volume is not available. In fact, the disk is no longer mounted. However, I can find it again and mount it straight away. A few attempts later, I have backed up my really important data, but the copying process always stops after a few seconds. This would take far too long for the entire disk.&lt;/p&gt;&#10;&lt;p&gt;The observed behavior - mount, copy for a few seconds, disk disappearing - was confirmed to me in a few related forum posts online, so it seems to be normal behavior.&lt;/p&gt;&#10;&lt;p&gt;I need another solution.&lt;/p&gt;&#10;&lt;h3 id="save-data-with-ddrescue"&gt;Save data with &lt;em&gt;ddrescue&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-09-10_ddrescue.jpg" alt="Image: ddrescue output when saving my broken SSD&amp;#39;s files to an external HDD."&gt;&lt;/figure&gt;&#10;After a quick online search, I installed the program &lt;a href="https://www.gnu.org/software/ddrescue/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ddrescue&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The documentation states some things very clearly. For example, that you should understand exactly what it does before you use it. Or the following:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&amp;ldquo;Never try to repair a file system on a drive with I/O errors; you will probably lose even more data. &amp;quot; - &lt;a href="https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ddrescue Manual&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;Now I&amp;rsquo;m wondering whether I made the same mistake with the block repair attempt above. Or am I on the safe side with Bad Block Recovery, since this is about data blocks on the disk and not the &amp;ldquo;overlying&amp;rdquo; file system?&lt;/p&gt;&#10;&lt;p&gt;It also says here that the so-called &lt;em&gt;mapfile&lt;/em&gt; is a core function of the program. It offers a great time advantage when aborting, restarting, merging backups and during data recovery. Therefore, it is not a bad idea to create the mapfile when creating an offline backup and to put it away safely. Overall, I found the tool extremely helpful, effective and easy to use and can only recommend it at this point.&lt;/p&gt;&#10;&lt;p&gt;Back to action: I find a very simple command to copy the entire data partition of my damaged NVME storage to an external hard drive:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ddrescue --sparse /dev/nvme0n1p3 /run/media/liveuser/myExternalHDD/datapartition&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;--sparse&lt;/code&gt; option skips areas of the source disk that are filled with zeros and can thus save a lot of space on the target disk.&lt;/p&gt;&#10;&lt;p&gt;The program takes a quarter of an hour to complete the entire run. As you can see in the picture above, it can actually save &lt;code&gt;99.6%&lt;/code&gt; of the data and even several months later I haven&amp;rsquo;t missed the remaining &lt;code&gt;0.4%&lt;/code&gt;. Lucky me.&lt;/p&gt;&#10;&lt;h3 id="verification-view-the-output-of-ddrescue"&gt;Verification: View the output of &lt;em&gt;ddrescue&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;In any case, the program ultimately saves a single file on my external disk: &lt;code&gt;datapartition.img&lt;/code&gt;&#10;I can view this image with &lt;code&gt;fdisk -l&lt;/code&gt;:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;datapartition.img: 236.89 GiB, 254356226048 bytes, 496789504 sectors&#10;Units: sectors of 1 * 512 = 512 bytes&#10;Sector size (logical/physical): 512 bytes / 512 bytes&#10;I/O size (minimum/optimal): 512 bytes / 512 bytes&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Everything is there, that&amp;rsquo;s good. Is all data included as well?&lt;/p&gt;&#10;&lt;p&gt;To check this, I mount the partition and look around using &lt;code&gt;cd&lt;/code&gt; and &lt;code&gt;ls -l&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sudo mkdir /mnt/olddisk &lt;span style="color:#75715e"&gt;# Mount point of the rescued data image&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sudo mount /run/media/liveuser/myExternalHDD/datapartition.img /mnt/olddisk&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Fortunately, everything that should be there was there. Now I could unmount the image, shut down the computer and have a new hard drive installed.&lt;/p&gt;&#10;&lt;h2 id="the-new-hard-drive"&gt;The new hard drive&lt;/h2&gt;&#10;&lt;p&gt;Here, too, I was lucky: my used laptop was still under warranty and so I got it returned within a week with a new (and twice as big) hard drive. Because I was so afraid of data theft, I removed my old hard drive before sending it. I felt pretty paranoid.&lt;/p&gt;&#10;&lt;p&gt;I could have saved myself this work, maybe, because &lt;em&gt;ddrescue&lt;/em&gt; has a software solution for paranoid people like me: the &lt;code&gt;--fill-mode&lt;/code&gt; option.&#10;This allows you to delete the &amp;ldquo;still good&amp;rdquo; blocks and sectors after the rescue has been completed. Only the sectors that are already defective remain. From the &lt;a href="https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html#Fill-mode" target="_blank" rel="noopener noreferrer" class="external-link"&gt;online documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ddrescue --fill-mode&lt;span style="color:#f92672"&gt;=&lt;/span&gt;+ --force /dev/zero bad_drive mapfile&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I&amp;rsquo;m not sure whether this would have worked with a disk like mine, which the on-board controller had set to &amp;ldquo;read-only&amp;rdquo;. Whatever.&lt;/p&gt;&#10;&lt;p&gt;At least I can now continue writing blog entries with a fully functional laptop ☺️.&lt;/p&gt;&#10;</description></item><item><title>Gitea act_runner: Jump-start issues</title><link>https://blog.schallbert.de/en/fix-gitea-runner/</link><pubDate>Fri, 30 Aug 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/fix-gitea-runner/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-08-30_badgateway_runner-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Bad Gateway error message from Gitea&amp;#39;&amp;#39;s act_runner at startup"&#10; title="Gitea act_runner: Jump-start issues" /&gt;&#10;&lt;aside class="update-box update-box--warn" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ⚠️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Gitea Retires `act_runner`&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-09-15T00:00:00Z"&gt;&#10; 2026-09-15&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; This article refers to an Actions implementation by Gitea, the &lt;code&gt;act_runner&lt;/code&gt;. It is derived from &lt;a href="https://github.com/nektos/act" target="_blank" rel="noopener noreferrer" class="external-link"&gt;nectos/act&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Gitea now uses &lt;a href="https://blog.gitea.com/release-of-runner-1.0.0/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;its own runner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The old runner should be replaced. More info: Read my post to &lt;a href="https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/"&gt;deploy hugo with Gitea Actions, docker, and caddy&lt;/a&gt;&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h2 id="problem-statement"&gt;Problem statement&lt;/h2&gt;&#10;&lt;p&gt;Since I run this page myself I experience problems starting &lt;em&gt;Gitea&lt;/em&gt; and &lt;em&gt;act_runner&lt;/em&gt;. Sometimes, the runner won&amp;rsquo;t start. It exits with status &lt;code&gt;-1&lt;/code&gt; and so my workflows wouldn&amp;rsquo;t run the website build, verification and deploy tasks.&lt;/p&gt;&#10;&lt;p&gt;The error message is always the same:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;[...]&#10;runner-1 | time=&amp;#34;2024-08-26T10:09:46Z&amp;#34; level=info msg=&amp;#34;Starting runner daemon&amp;#34;&#10;runner-1 | time=&amp;#34;2024-08-26T10:09:46Z&amp;#34; level=error msg=&amp;#34;fail to invoke Declare&amp;#34; error=&amp;#34;unavailable: 502 Bad Gateway&amp;#34;&#10;runner-1 | Error: unavailable: 502 Bad Gateway&#10;runner-1 exited with code 1&#10;gitea | 2024/08/26 10:09:46 cmd/web.go:242:runWeb() [I] Starting Gitea on PID: 16&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Ok, the runner cannot hook onto &lt;em&gt;Gitea&lt;/em&gt;. When I restart all services via &lt;code&gt;docker restart&lt;/code&gt;, the &lt;em&gt;act_runner&lt;/em&gt; container tells me it would be missing a network with ID &lt;code&gt;&amp;lt;long hexcode ID&amp;gt;&lt;/code&gt;&lt;/p&gt;&#10;&lt;h2 id="interim-corrective-action"&gt;Interim corrective action&lt;/h2&gt;&#10;&lt;p&gt;I didn&amp;rsquo;t find time for resolving this issue. So I just ran docker compose twice:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# schallbert server-console&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker compose -f /path/to/giteas/composefile up -d&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The second time, &lt;em&gt;act_runner&lt;/em&gt; would start successfully (because Gitea is ready):&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;gitea | 2024/08/26 10:38:17 routers/init.go:116:InitWebInstalled() [I] Git version: 2.45.2 (home: /data/gitea/home)&#10;runner-1 | time=&amp;#34;2024-08-26T10:38:22Z&amp;#34; level=info msg=&amp;#34;Starting runner daemon&amp;#34;&#10;runner-1 | time=&amp;#34;2024-08-26T10:38:22Z&amp;#34; level=info msg=&amp;#34;runner: action-runner, with version: v0.2.10, with labels: [ubuntu-latest], declare successfully&amp;#34;&#10;runner-1 exited with code 0&#10;runner-1 | time=&amp;#34;2024-08-26T10:40:32Z&amp;#34; level=info msg=&amp;#34;Started runner daemon&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Not very satisfying to do things twice. Plus, I did this manually. So why is that?&lt;/p&gt;&#10;&lt;h2 id="root-cause-analysis"&gt;Root cause analysis&lt;/h2&gt;&#10;&lt;p&gt;First I thought it was due to &lt;em&gt;Caddy&lt;/em&gt; not being ready as it runs the reverse proxy, connecting &lt;em&gt;Gitea&lt;/em&gt; to the internet. In reality, &lt;em&gt;Gitea&lt;/em&gt; is the culprit: At the time, &lt;em&gt;docker&lt;/em&gt; starts &lt;em&gt;act_runner&lt;/em&gt;, its startup procedure wouldn&amp;rsquo;t be complete. So I try to find out how to manage dependencies in docker.&lt;/p&gt;&#10;&lt;h2 id="solution-reflecting-dependencies"&gt;Solution: Reflecting dependencies&lt;/h2&gt;&#10;&lt;p&gt;First, I try linking gitea to the runner in the docker compose file.&lt;/p&gt;&#10;&lt;h3 id="depends_on"&gt;depends_on&lt;/h3&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;## service: runner&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;## [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;depends_on&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;gitea&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;condition&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;service_started&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Well, still act_runner seems to be starting early. Docker just makes sure it has started the container, and does not wait for it to return a &lt;code&gt;healthy&lt;/code&gt; signal.&lt;/p&gt;&#10;&lt;h3 id="healthcheck"&gt;Healthcheck&lt;/h3&gt;&#10;&lt;p&gt;So I have to make sure that gitea&amp;rsquo;s startup procedure is complete. For this, docker provides the condition &lt;code&gt;service_healthy&lt;/code&gt;. I adjust the runner configuration like so:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;## service: runner&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;## [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;depends_on&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;gitea&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;condition&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;service_healthy&lt;/span&gt; &lt;span style="color:#75715e"&gt;# required so runner can attach to gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;restart&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;service_healthy&lt;/code&gt; qualifier is returned by the &lt;code&gt;healthcheck&lt;/code&gt; function. It consists of a so-called &lt;code&gt;test&lt;/code&gt; and some environment parameters that define intervall, number of retries, delay and timeout conditions for checking service ready.&lt;/p&gt;&#10;&lt;p&gt;For the test I chose a simple command, assuming that Gitea (and, implicitly, Caddy too) would be healthy once it is able to respond to a GET request from &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;## service: gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;## [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;healthcheck&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;test&lt;/span&gt;: [&lt;span style="color:#e6db74"&gt;&amp;#34;CMD&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;curl&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;-f&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;https://git.schallbert.de/&amp;#34;&lt;/span&gt;] &lt;span style="color:#75715e"&gt;# checks if gitea is available&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;interval&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;10s&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;retries&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;start_period&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;30s&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;timeout&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;10s&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With these lines, docker checks if gitea is &amp;ldquo;healthy&amp;rdquo; and only then starts &lt;em&gt;act_runner&lt;/em&gt;. This permanently solved the problem and both &lt;em&gt;Gitea&lt;/em&gt; and &lt;em&gt;act_runner&lt;/em&gt; are stable.&lt;/p&gt;&#10;</description></item><item><title>Sending error logs</title><link>https://blog.schallbert.de/en/server-deploy-logging/</link><pubDate>Tue, 20 Aug 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/server-deploy-logging/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-08-20_deploy_logging-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: A notification message sent on error"&#10; title="Sending error logs" /&gt;&#10;&lt;p&gt;In this article I&amp;rsquo;ll look at how to set up &amp;ldquo;monitoring&amp;rdquo; for my server. Applications and services should be able to send me notifications in the event of an error.&lt;/p&gt;&#10;&lt;h2 id="what-is-this-about"&gt;What is this about?&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Learn about and select sending mechanisms&lt;/li&gt;&#10;&lt;li&gt;Write test messages and verify the automation&lt;/li&gt;&#10;&lt;li&gt;Automatically send error report from &lt;em&gt;Borgmatic&lt;/em&gt;&lt;/li&gt;&#10;&lt;li&gt;Send runner logs through &lt;em&gt;Gitea&lt;/em&gt;&lt;/li&gt;&#10;&lt;li&gt;Notification when logging into my server via &lt;em&gt;ssh&lt;/em&gt;&lt;/li&gt;&#10;&lt;li&gt;Create and send logs for server updates / server errors&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="verification-and-monitoring"&gt;Verification and monitoring&lt;/h3&gt;&#10;&lt;p&gt;After running an automation, I want to know whether it was carried out successfully and whether all programs and services started their work as expected. This should apply to any automation - whether it is specifically sending an update, creating automatic backups or an action from &lt;em&gt;Gitea&lt;/em&gt;, it doesn&amp;rsquo;t matter.&lt;/p&gt;&#10;&lt;p&gt;Normally I would use reporting mechanisms from &lt;em&gt;act_runner&lt;/em&gt; for something like this. In the case of a server update, the runner is not available due to the &lt;a href="https://blog.schallbert.de/en/server-config-version-control/#preliminary-considerations"&gt;circular reference&lt;/a&gt; already mentioned in the article &lt;a href="https://blog.schallbert.de/en/server-config-deploy/"&gt;Rolling out the server configuration&lt;/a&gt;, as all applications have to be shut down temporarily.&lt;/p&gt;&#10;&lt;p&gt;In addition, applications may have their own procedures for monitoring. So I have to look at mechanisms that allow me to easily access the information.&lt;/p&gt;&#10;&lt;h3 id="what-characterizes-good-monitoring-for-me"&gt;What characterizes good monitoring for me?&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;It is unobtrusive, so it only reports in the event of an error or unusual occurrence.&lt;/li&gt;&#10;&lt;li&gt;It provides specific information and error messages that are easy to understand.&lt;/li&gt;&#10;&lt;li&gt;It uses a message channel that works even if the system being monitored crashes.&lt;/li&gt;&#10;&lt;li&gt;It presents reports and error messages in isolation from other topics and does not mix anything.&lt;/li&gt;&#10;&lt;li&gt;It is brief.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="methods-of-automatic-reporting"&gt;Methods of automatic reporting&lt;/h2&gt;&#10;&lt;p&gt;I will break this section down. In the general part, I will discuss the on-board tool for asynchronous monitoring that is available to me on the Ubuntu server. After that, I will look at the solutions that are partly built into my services or the applications that are compatible with them. I do not want to limit myself to the classic tool of email, but also look at &amp;ldquo;more modern&amp;rdquo; communication channels such as messenger programs or RSS feeds.&lt;/p&gt;&#10;&lt;h3 id="mail-via-console---curl"&gt;Mail via console - &lt;em&gt;curl&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;Sending emails as notifications is common practice in many companies. With Linux, this can usually be done without any additional programs: The standard &lt;a href="https://curl.se/docs/manpage.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;curl&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; can help here.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;curl&lt;/em&gt; is a program for transferring data from or to a server. If I enter my blog as the target, I get the HTML page output as a text file on the console:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;curl https://blog.schallbert.de&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This made me notice how much unnecessary data my blog software generates. I&amp;rsquo;ll have to clean that up later. Back to the topic: You can also use &lt;em&gt;curl&lt;/em&gt; to access any web backend - for example a mail server:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# copied from https://stackoverflow.com/questions/8260858/how-to-send-email-from-terminal&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;curl --url &lt;span style="color:#e6db74"&gt;&amp;#39;smtps://smtp.gmail.com:465&amp;#39;&lt;/span&gt; --ssl-reqd &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --mail-from &lt;span style="color:#e6db74"&gt;&amp;#39;from-email@gmail.com&amp;#39;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --mail-rcpt &lt;span style="color:#e6db74"&gt;&amp;#39;to-email@gmail.com&amp;#39;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; --user &lt;span style="color:#e6db74"&gt;&amp;#39;from-email@gmail.com:YourPassword&amp;#39;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -T &amp;lt;&lt;span style="color:#f92672"&gt;(&lt;/span&gt;echo -e &lt;span style="color:#e6db74"&gt;&amp;#39;From: from-email@gmail.com\nTo: to-email@gmail.com\nSubject: Curl Test\n\nHello&amp;#39;&lt;/span&gt;&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This only works for my mail provider if I allow logins from external clients. Google, for example, calls these &amp;ldquo;less secure apps&amp;rdquo;. As described in my post &lt;a href="https://blog.schallbert.de/en/server-config-version-control/#lets-get-to-work"&gt;Server configuration with Git&lt;/a&gt;, I&amp;rsquo;m not a fan of writing secrets into anything - so I would rather not use the direct route via &lt;em&gt;curl&lt;/em&gt;.&lt;/p&gt;&#10;&lt;h3 id="mail-via-console---mail-mailx-mailutils-swaks"&gt;Mail via console - &lt;em&gt;mail&lt;/em&gt;, &lt;em&gt;mailx&lt;/em&gt;, &lt;em&gt;mailutils&lt;/em&gt;, &lt;em&gt;swaks&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;If you don&amp;rsquo;t want to always provide all the configuration information for the server and secrets, there are various handy tools for the console such as &lt;a href="https://mailutils.org/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;mailutils&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; or &lt;a href="https://github.com/jetmore/swaks" target="_blank" rel="noopener noreferrer" class="external-link"&gt;swaks&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Here, the connection to the mail server is configured once using the tool and can be stored in environment variables, for example. The syntax varies from program to program, but an email always drops out at the end.&lt;/p&gt;&#10;&lt;p&gt;The only major disadvantage for me is that information domains are mixed up. I would not want another &amp;ldquo;report thread&amp;rdquo; in my emails that I&amp;rsquo;d have to search for in the mass of messages. Other notification media in contrast allow me to set an automatic expiration date so that they disappear from my list after a set time.&lt;/p&gt;&#10;&lt;h3 id="create-rss-feed"&gt;Create RSS feed&lt;/h3&gt;&#10;&lt;p&gt;Unusual but possible: I could create the monitoring as an &lt;a href="https://en.wikipedia.org/wiki/RSS" target="_blank" rel="noopener noreferrer" class="external-link"&gt;RSS feed&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; like my blog (&lt;a href="https://blog.schallbert.de/en/index.xml"&gt;schallberts-blog-feed&lt;/a&gt;) has e.g. using a Jekyll instance and put it online as a website. This would be easy to subscribe to, were readable with practically any reader and I could even set it up separately for each application. But there are obvious disadvantages:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;High effort: &lt;em&gt;Gitea&lt;/em&gt; runner with &lt;em&gt;Jekyll&lt;/em&gt; instance, web server and subdomain required.&lt;/li&gt;&#10;&lt;li&gt;Publicly available: Suddenly build processes, updates, upgrades and error messages are accessible to everyone.&lt;/li&gt;&#10;&lt;li&gt;Error-prone: If &lt;em&gt;Gitea&lt;/em&gt;, &lt;em&gt;act_runner&lt;/em&gt;, my proxy or the web server crashes, I don&amp;rsquo;t get any reports.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;With the last point at the latest, this option is out of the question for me. I want to get a report when my applications don&amp;rsquo;t do what they&amp;rsquo;re supposed to.&#10;Let&amp;rsquo;s take a look at the applications I already run and see how they tackle this problem.&lt;/p&gt;&#10;&lt;h3 id="borgmatic"&gt;&lt;em&gt;borgmatic&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;Borgmatic is compatible with a lot of &lt;a href="https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;monitoring options&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. These include &lt;a href="https://github.com/caronc/apprise" target="_blank" rel="noopener noreferrer" class="external-link"&gt;apprise&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, &lt;a href="https://ntfy.sh/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ntfy&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, &lt;a href="https://healthchecks.io/docs/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;healthchecks&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, &lt;a href="https://cronitor.io/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;cronitor&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, &lt;a href="https://www.pagerduty.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;pagerduty&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, &lt;a href="https://cronhub.io/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;cronhub&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and &lt;a href="https://grafana.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;grafana&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;em&gt;Apprise&lt;/em&gt; is a library. It is open source and can be integrated into an existing application as a dependency. Like an adapter, it enables asynchronous communication between the application and various communication services such as SMS, mail, messenger (e.g. &lt;a href="https://signal.org/de/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Signal&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;), various home automation systems or the notification mechanism of various operating systems. The latter, however, only works on the local machine. The trigger for communication must always come from the application.&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;ntfy&lt;/em&gt; is a push notification service. It is open source and can be self-hosted or used as a service via web application. &lt;em&gt;Apprise&lt;/em&gt;, for example, supports &lt;em&gt;ntfy&lt;/em&gt; as a communication service. The structure is quite simple and works like &lt;a href="https://de.wikipedia.org/wiki/MQTT" target="_blank" rel="noopener noreferrer" class="external-link"&gt;MQTT&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; via publication subscription / broker client mechanism, but HTTP-based.&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;Healthchecks&lt;/em&gt; is a service. It is open source and can be self-hosted. The service is there to monitor regular activities and can act as a dead man&amp;rsquo;s switch: If, contrary to expectations, there is no response from the monitored program, it can raise an error message itself. This can in turn be forwarded to various communication services.&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;Cronitor&lt;/em&gt; is a monitoring solution and web application that, in addition to the notifications I need, provides a lot of analysis tools, performance measurements and metrics - mostly for money. There is a free &amp;ldquo;hacker&amp;rdquo; account with limited functionality, but this tool is also far too big and complex for me.&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;PagerDuty&lt;/em&gt; sees itself as a commercial &amp;ldquo;operations&amp;rdquo; platform that provides &amp;ldquo;incident management&amp;rdquo;, automation, &amp;ldquo;business operations&amp;rdquo;, &amp;ldquo;AIOps&amp;rdquo; etc. It&amp;rsquo;s out for me straight away. At the latest when I read the word &amp;ldquo;AIOps&amp;rdquo; 😅&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;cronhub&lt;/em&gt; looks like a commercial web application to me that, like &lt;em&gt;Healthchecks&lt;/em&gt;, can create cron jobs, monitor them and report errors. It is of no interest to me because it does not seem to be open source and I could not host it myself.&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;Grafana&lt;/em&gt; is an open source web application that can either be self-hosted or used as a cloud service. Although many larger companies and projects use the application, it is orders of magnitude too extensive and feature-rich for my purposes.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="fail2ban"&gt;&lt;em&gt;fail2ban&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;Fail2ban does not have an automation solution like &lt;em&gt;borgmatic&lt;/em&gt;. It simply creates log files that need to be evaluated in order to obtain content for notifications. At the moment I cannot think of anything that I absolutely need to know about Fail2ban. So I am not creating any reports for this for now.&lt;/p&gt;&#10;&lt;h3 id="gitea"&gt;&lt;em&gt;Gitea&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;Gitea allows the log files to be configured very precisely. Access logs can be written out separately from service logs, repository logs or action logs and then processed further. According to my research, Gitea only offers a &lt;a href="https://docs.gitea.com/next/administration/config-cheat-sheet#mailer-mailer" target="_blank" rel="noopener noreferrer" class="external-link"&gt;mailer&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; as a notification system. In my opinion, this is primarily intended for repository and action logs.&lt;/p&gt;&#10;&lt;p&gt;Here, too, it would be most beneficial for me to analyze the logs and create a report myself if necessary.&lt;/p&gt;&#10;&lt;h3 id="server"&gt;Server&lt;/h3&gt;&#10;&lt;p&gt;I already roll out my server configuration files using a script. So, in the event of an error, I could redirect the logs to a file and attach them to a report in any channel.&lt;/p&gt;&#10;&lt;p&gt;In addition, successful logins on the server would be worth a message. Then I can immediately determine whether it was me or not.&lt;/p&gt;&#10;&lt;h2 id="selecting-the-reporting-program"&gt;Selecting the reporting program&lt;/h2&gt;&#10;&lt;p&gt;I not only have to select a monitoring program, but also choose a communications service through which the reports are sent.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Program&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Advantage&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Disadvantage&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;em&gt;curl&lt;/em&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;simple&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;configuration must be provided&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;works super easily with &lt;em&gt;ntfy&lt;/em&gt;&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No monitoring if the server crashes&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Onboard tools&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;no service required&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;em&gt;Apprise&lt;/em&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;enables integration of the Signal API&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Complex in interaction with external services&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;no service required&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;em&gt;Healthchecks&lt;/em&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Can act as a watchdog/dead man switch&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Registration required&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Can be hosted locally&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Local hosting contradicts the watchdog concept&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;I only have a small server with a few applications, so I keep it as simple as possible and choose &lt;em&gt;Apprise&lt;/em&gt; for services like &lt;em&gt;borgmatic&lt;/em&gt;, which come with this library anyway, and &lt;em&gt;curl&lt;/em&gt; for those whose reporting mechanics I have to program myself.&lt;/p&gt;&#10;&lt;h3 id="ntfy"&gt;Communication channel&lt;/h3&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Program&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Advantage&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Disadvantage&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;SMS&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;High reliability&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Registration required&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Limited to a few characters&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No attachments possible&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No topics, domain mixing&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mail&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;easy to set up&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Poor searchability&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;RSS feed&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Good sortability&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Complicated to set up, error-prone&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Client very lightweight&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Local hosting contradicts watchdog concept&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Content publicly available&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;em&gt;ntfy&lt;/em&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Simple and lightweight&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Registration optional for web use&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Client purpose-oriented&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Insecure: No encryption without registration&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Free for small users&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Independent of your own machine&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;em&gt;Signal&lt;/em&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&amp;ldquo;Note to self&amp;rdquo; easy to set up&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Setting up and configuring the Signal API complex&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Independent of your own machine&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Topics somewhat difficult to implement&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Useless if the server crashes&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;SMS, mail and RSS feed are already out of the question for me due to some of the disadvantages already explained above. So that leaves &lt;em&gt;ntfy&lt;/em&gt; and &lt;em&gt;Signal&lt;/em&gt;. &lt;em&gt;ntfy&lt;/em&gt; impresses with its simplicity: send an HTTP PUSH request to a self-defined &amp;ldquo;topic&amp;rdquo; and subscribe to it on your cell phone - done. It is also easy to expand, because with self-hosting I can later increase security if necessary (encryption) and control the sending behavior. &lt;em&gt;Signal&lt;/em&gt;, on the other hand, requires a separate client with relatively complex configuration. In addition, the devices would have to be connected to the server and cell phone, so it is not easy to add new subscribers. On the other hand, the transmission is well secured end-to-end, I do not have to register and it is also free.&lt;/p&gt;&#10;&lt;p&gt;For now, I have decided to go for the less complex solution with &lt;em&gt;ntfy&lt;/em&gt;.&lt;/p&gt;&#10;&lt;h2 id="logging"&gt;Logging&lt;/h2&gt;&#10;&lt;p&gt;Good. Now it&amp;rsquo;s clear that I&amp;rsquo;m calling home using &lt;em&gt;curl&lt;/em&gt; and &lt;em&gt;apprise&lt;/em&gt; via the &lt;em&gt;ntfy&lt;/em&gt; service. Let&amp;rsquo;s see what content needs to be transmitted and how to make it as unobtrusive as possible, but still short and concise.&lt;/p&gt;&#10;&lt;h3 id="when-should-logs-be-sent"&gt;When should logs be sent?&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;em&gt;borgmatic&lt;/em&gt;: If a backup fails&lt;/li&gt;&#10;&lt;li&gt;If I successfully log in to my server or Gitea&lt;/li&gt;&#10;&lt;li&gt;After rolling out an update to the server configuration&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;Docker&lt;/em&gt;: If an application fails or doesn&amp;rsquo;t start&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;Gitea&lt;/em&gt;: Failed run of &lt;em&gt;act_runner&lt;/em&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="what-should-i-include-in-a-report"&gt;What should I include in a report?&lt;/h3&gt;&#10;&lt;p&gt;I want the classic &amp;ldquo;W questions&amp;rdquo; answered.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;When did it happen (time stamp)?&lt;/li&gt;&#10;&lt;li&gt;Which application is reporting?&lt;/li&gt;&#10;&lt;li&gt;What happened?&lt;/li&gt;&#10;&lt;li&gt;Where (module, line of code) etc. did it happen?&lt;/li&gt;&#10;&lt;li&gt;How many were injured (severity, recovery)?&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;A notification then looks something like this:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;TIMESTAMP APPLICATION PRIORITY MESSAGE EFFECT DETAIL&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="rough-version-of-a-report"&gt;Rough version of a report&lt;/h3&gt;&#10;&lt;p&gt;An &lt;em&gt;ntfy&lt;/em&gt; message could look something like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;curl &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Title: Error Borgmatic&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Priority: urgent&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Tags: warning&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -d &lt;span style="color:#e6db74"&gt;&amp;#34;YYYY-MM-DD HH:MM:SS Backup creation aborted. Access to repository blocked&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ntfy.sh/schallberts-topic&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-08-20_ntfytest.jpg" alt="Image: First ntfy test on my phone app sent with curl"&gt;&lt;/figure&gt;&#10;I install the corresponding app on my phone, register on the &amp;ldquo;Topic&amp;rdquo; and send the message. It&amp;rsquo;s nice when things just work!&#10;Aha, the app shows the time of receipt. That&amp;rsquo;s accurate enough for me.&lt;/p&gt;&#10;&lt;h2 id="implementation"&gt;Implementation&lt;/h2&gt;&#10;&lt;p&gt;Here I&amp;rsquo;ll take a look at all the services for which I&amp;rsquo;d like to set up notifications one by one.&lt;/p&gt;&#10;&lt;h3 id="borgmatic-1"&gt;&lt;em&gt;borgmatic&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;Let&amp;rsquo;s start with a pilot test in small steps. First I configure Borgmatic to send a message via &lt;em&gt;Apprise&lt;/em&gt; to &lt;em&gt;ntfy&lt;/em&gt; if the backup creation fails:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /borgmatic.d/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;on_error&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#ae81ff"&gt;echo &amp;#34;Error while creating a backup.&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#ae81ff"&gt;apprise -vv --title &amp;#34;Borgmatic Error&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;--&lt;span style="color:#ae81ff"&gt;body &amp;#34;Could not run {output}. Aborted {error}.&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;ntfy://schallberts-topic&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then I test the command by calling &lt;em&gt;apprise&lt;/em&gt; within the Borgmatic container. And indeed, it works. But getting here took me an hour, as the &lt;code&gt;yml&lt;/code&gt; with its syntax rules even interprets within strings and &lt;em&gt;borgmatic&lt;/em&gt; constantly refused to read the configuration file due to &lt;code&gt;:&lt;/code&gt; and &lt;code&gt;-&lt;/code&gt; characters. If you see an error similar to this one:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;At &amp;#39;on_error[1]&amp;#39;: {&amp;#39;apprise -vv --title &amp;#34;Borgmatic Error&amp;#34; --body &amp;#34;Could not run {output}&amp;#39;: &amp;#39;Aborted {error}.&amp;#34; ntfy://schallberts-topic&amp;#39;} is not of type &amp;#39;string&amp;#39;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This shows that the interpretation of characters or indentations went wrong and the punctuation needs to be checked. Alternatively, the pipe operator &lt;code&gt;|&lt;/code&gt; can be used to combine a command. Reference: &lt;a href="https://yaml.org/spec/1.2-old/spec.html#id2795688" target="_blank" rel="noopener noreferrer" class="external-link"&gt;yml specification&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;h3 id="server-1"&gt;Server&lt;/h3&gt;&#10;&lt;p&gt;An application of &lt;em&gt;ntfy&lt;/em&gt; to monitor logins on a server can already be found in the &lt;a href="https://docs.ntfy.sh/examples/#ssh-login-alerts" target="_blank" rel="noopener noreferrer" class="external-link"&gt;documentation of &lt;em&gt;ntfy&lt;/em&gt; itself&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The description shows that something like this is easy to implement yourself and gives a great example using Pluggable Authentication Modules (&lt;a href="https://en.wikipedia.org/wiki/Linux_PAM" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PAM library&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;Insert the following code at the end of the &lt;code&gt;sshd&lt;/code&gt; file in the &lt;code&gt;etc/pam.d&lt;/code&gt; directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;session optional pam_exec.so /usr/bin/ntfy-ssh-login.sh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This line tells &lt;em&gt;PAM&lt;/em&gt; that when a &lt;code&gt;session&lt;/code&gt; is opened via &lt;code&gt;ssh&lt;/code&gt;, it should call the executing module &lt;code&gt;pam_exec&lt;/code&gt;, which then runs the script specified below. The value &lt;code&gt;optional&lt;/code&gt; means that the configuration file should continue to be run even if the action fails. More details on how to use &lt;em&gt;PAM&lt;/em&gt; can be found, for example, on &lt;a href="https://www.baeldung.com/linux/pam-ssh-login-notifications" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Baeldung&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Then you simply have to store the &lt;em&gt;ntfy&lt;/em&gt; call in the &lt;code&gt;ntfy-ssh-login.sh&lt;/code&gt; script when the script determines that PAM has detected an &lt;code&gt;open_session&lt;/code&gt; event.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s exactly how I implemented it and it works straight away. Great! The only disadvantage: I made this modification directly on the server. Without a container and outside of my configuration backup. If I now have to set up the server again for some reason, the change in &lt;em&gt;PAM&lt;/em&gt; is lost and I won&amp;rsquo;t receive any more notifications until I manually enter the change again.&lt;/p&gt;&#10;&lt;h3 id="server-configuration"&gt;Server configuration&lt;/h3&gt;&#10;&lt;p&gt;To send a report after running the configuration automation, I actually only have to adapt the &lt;a href="https://blog.schallbert.de/en/server-config-deploy/#the-finished-automation"&gt;server-config-action&lt;/a&gt; script that I wrote in the last article.&lt;/p&gt;&#10;&lt;p&gt;First, I want to be informed when the script runs without errors. To do this, I add a &lt;em&gt;curl&lt;/em&gt; command at the end of the file.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# server-config-action.sh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# action commands...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# send success notification&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;curl &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Title: server-config-action&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Priority: low&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Tags: white_check_mark&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -d &lt;span style="color:#e6db74"&gt;&amp;#34;Rollout successful&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ntfy.sh/schallbert-server-config-push-topic&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;However, if the script does not run without errors, I would like to do the following:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Cancel actions after the error occurs&lt;/li&gt;&#10;&lt;li&gt;Create an error log&lt;/li&gt;&#10;&lt;li&gt;Send this log&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;I can achieve the first point by adding a trap for errors:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# server-config-action.sh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;trap &lt;span style="color:#e6db74"&gt;&amp;#39;handle_error $LINENO&amp;#39;&lt;/span&gt; ERR&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# action commands...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This calls the function &lt;code&gt;handle_error&lt;/code&gt;. It is given the line number where the error &lt;code&gt;ERR&lt;/code&gt; occurred. It also covers errors that can occur when restarting the container. I create the error log by redirecting the output of the individual script commands. I achieve this with the following line:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# server-config-action.sh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;exec 3&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt; 1&amp;gt;server-config-action.log 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# action commands ...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With this command, any standard output &lt;code&gt;stdout&lt;/code&gt; should be passed on to the file descriptor &lt;code&gt;3&lt;/code&gt;, specified here with the log file &lt;code&gt;server-config-action-log&lt;/code&gt;, overwriting it. For &amp;ldquo;append&amp;rdquo; there would have to be two redirection operators &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt;. The log file thus replaces the console output, which I still had at this point in the previous article.&lt;/p&gt;&#10;&lt;p&gt;To have the log sent to me, I now define the following function at the beginning of the Bash script:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# server-config-action.sh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# on error, send a notification&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;handle_error&lt;span style="color:#f92672"&gt;()&lt;/span&gt; &lt;span style="color:#f92672"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# stop redirecting to file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; exec 1&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;3&lt;/span&gt; 1&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; curl &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Title: server-config-action FAILED&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Priority: high&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Tags: x&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -T server-config-action.log &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -H &lt;span style="color:#e6db74"&gt;&amp;#34;Filename: server-config-action.log&amp;#34;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ntfy.sh/schallbert-server-config-push-topic&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; exit &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# set error trap&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# redirect stdout and stderr to file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# action commands...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-08-20_ntfytest_allchannels.jpg" alt="Image: Phone screenshot of my ntfy messages"&gt;&lt;/figure&gt;&#10;&lt;h3 id="gitea-1"&gt;&lt;em&gt;Gitea&lt;/em&gt;&lt;/h3&gt;&#10;&lt;p&gt;For Github Actions reports, there is already an existing example at &lt;a href="https://docs.ntfy.sh/examples/#github-actions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ntfy_examples/#github-actions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. And the best part: Since the &lt;em&gt;act_runner&lt;/em&gt; from &lt;em&gt;Gitea&lt;/em&gt; is compatible with &lt;em&gt;Github Actions&lt;/em&gt; at &lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables" target="_blank" rel="noopener noreferrer" class="external-link"&gt;in the environment parameters&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, sending to &lt;em&gt;ntfy&lt;/em&gt; works for me straight away. In the runner&amp;rsquo;s workflow file, all you have to do is enter the &lt;em&gt;curl&lt;/em&gt; command specified on the website and you&amp;rsquo;re done.&lt;/p&gt;&#10;&lt;h2 id="result"&gt;Result&lt;/h2&gt;&#10;&lt;p&gt;Five notifications about the most important processes on my server have now been set up. I have understood the underlying mechanics and can create additional notifications at any time if I need them. If I trigger all notifications as a test, my smartphone will display the as shown.&lt;/p&gt;&#10;&lt;p&gt;But I have not yet achieved independence from the system to be monitored. All notifications come from the affected machine and there are sometimes &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/#reverse-proxy-connection-refused"&gt;even dependencies&lt;/a&gt; between Docker containers. For example, the &lt;em&gt;act_runner&lt;/em&gt; has to connect to &lt;em&gt;Gitea&lt;/em&gt; via websockets. And that only works if &lt;em&gt;Caddy&lt;/em&gt; provides the reverse proxy.&lt;/p&gt;&#10;&lt;p&gt;If I find out in the next few months that a service &amp;ldquo;under my radar&amp;rdquo; is no longer able to work, I will have to establish independence.&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;Context for setup with &lt;a href="https://docs.ntfy.sh/#getting-started" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ntfy&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:2"&gt;&#10;&lt;p&gt;Context for the &lt;a href="https://github.com/caronc/apprise/wiki/Notify_signal" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Signal API&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:3"&gt;&#10;&lt;p&gt;Report by &lt;code&gt;asad-awadia&lt;/code&gt; on &lt;a href="https://blog.aawadia.dev/2023/04/24/signal-api/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;setting up the Signal API&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:4"&gt;&#10;&lt;p&gt;Healthchecks is directly available as a &lt;a href="https://hub.docker.com/r/healthchecks/healthchecks" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Docker image&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:5"&gt;&#10;&lt;p&gt;Incidentally, the operators &lt;code&gt;&amp;lt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; are not difficult for me to remember, because I used to have a lot to do with the programming language &lt;code&gt;C&lt;/code&gt;. There - and in many other programming languages too - these are shift operators that can &amp;ldquo;shift&amp;rdquo; a value bit by bit or move one field to another. In the &lt;a href="https://en.wikipedia.org/wiki/Reduced_instruction_set_computer" target="_blank" rel="noopener noreferrer" class="external-link"&gt;RISC&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; architectures that I used at the time, the shift operation was &amp;ldquo;cheap&amp;rdquo;, i.e. very fast and memory-saving.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>Server configuration rollout</title><link>https://blog.schallbert.de/en/server-config-deploy/</link><pubDate>Wed, 31 Jul 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/server-config-deploy/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-07-31-server-configdeploy-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Diagram showing a config file rollout on Schallbert&amp;#39;&amp;#39;s server"&#10; title="Server configuration rollout" /&gt;&#10;&lt;p&gt;In the previous article, I &lt;a href="https://blog.schallbert.de/en/server-config-version-control/"&gt;brought my configuration files under version control&lt;/a&gt;. Now I want to automatically install the updates provided on the server.&lt;/p&gt;&#10;&lt;h2 id="what-is-this-about"&gt;What is this about?&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Writing a script to automatically detect the update trigger&lt;/li&gt;&#10;&lt;li&gt;Server applications should be shut down and a backup copy created&lt;/li&gt;&#10;&lt;li&gt;The script should distribute the configuration on the system&lt;/li&gt;&#10;&lt;li&gt;All applications should then be restarted&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="ways-and-possibilities"&gt;Ways and possibilities&lt;/h2&gt;&#10;&lt;p&gt;Here, too, I spent several hours researching. For larger projects, infrastructure experts seem to use specialized automation tools. These include &lt;a href="https://docs.ansible.com/ansible/latest/getting_started/index.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Ansible&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, simpler tools such as &lt;a href="https://www.cdi.st/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;cdist&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; or even &lt;a href="https://kubernetes.io/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Kubernetes&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for highly scalable services.&lt;/p&gt;&#10;&lt;h3 id="automation-services-ansible-cdist-kubernetes"&gt;Automation services Ansible, cdist, Kubernetes&lt;/h3&gt;&#10;&lt;p&gt;Ansible and cdist seem to follow a similar concept: On the source machine (in this example my laptop) I create the configuration for my server and store this and the instructions for configuring my services in a &amp;ldquo;playbook&amp;rdquo; (Ansible) or in &amp;ldquo;types&amp;rdquo; (cdist).&lt;/p&gt;&#10;&lt;p&gt;To put it simply - as I understand it - the tool then takes care of building the configuration at the push of a button, dialing into the target host via &lt;code&gt;ssh&lt;/code&gt;, pushing it over there and then starting it. For Ansible, there are even tutorials like this one for my scenario with &lt;code&gt;docker-compose&lt;/code&gt;, which makes getting started even easier.&lt;/p&gt;&#10;&lt;p&gt;Kubernetes takes a different approach. It sees itself as more of a container manager, load balancer and scaling agent, but can do similar things for my purposes.&lt;/p&gt;&#10;&lt;h3 id="my-approach"&gt;My approach&lt;/h3&gt;&#10;&lt;p&gt;I, on the other hand, only need a fraction of the capabilities of these programs. I am also put off by the &amp;ldquo;additional&amp;rdquo; &lt;code&gt;ssh&lt;/code&gt; channel, the configuration effort, the additional programs sometimes required on the target system, and the necessary reading and selection of the best tool for me. Because thanks to my very simple pipeline from the last article, the configuration is already on my server. It &amp;ldquo;only&amp;rdquo; needs to be copied to the right places and the affected services restarted.&lt;/p&gt;&#10;&lt;p&gt;Therefore, I am trying to solve this problem using on-board tools, my brain in working order and a few searches in relevant forums on the topics &lt;a href="https://superuser.com/questions/181517/how-to-execute-a-command-whenever-a-file-changes" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&amp;ldquo;executing a script when a file changes&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and &lt;a href="https://www.freecodecamp.org/news/copy-a-directory-in-linux-how-to-cp-a-folder-in-the-command-line-in-linux-and-unix-macos/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&amp;ldquo;copying folder structures in Linux&amp;rdquo;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="risk-of-circular-reference"&gt;Risk of circular reference&lt;/h3&gt;&#10;&lt;p&gt;However, I am taking a risk: Since the deployment runs via Gitea, but the configuration affects Gitea itself, if there is an error in this module I can no longer change or reset anything: The Gitea service is then broken. I would have to get the configuration up and running again manually on the server.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;ll try it out anyway and see if I am actually confronted with such a problem. If so, I&amp;rsquo;ll just switch to &lt;code&gt;cdist&lt;/code&gt; and document it in a separate article! 🤗&lt;/p&gt;&#10;&lt;h2 id="preparation-create-folder-system-and-scripts"&gt;Preparation: Create folder system and scripts&lt;/h2&gt;&#10;&lt;p&gt;I think about it for a moment and create a few folders in the server file system:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /opt/server-config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;mkdir automation-hooks-trigger&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;mkdir automation-hooks-handler&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;These folders should represent the two sides of the automation. &lt;code&gt;trigger&lt;/code&gt; contains text files that can be manipulated from the (web) service side. For example, &lt;em&gt;act_runner&lt;/em&gt; should write the file &lt;code&gt;server-config-update&lt;/code&gt; as soon as an update is available.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;service &amp;ndash;&amp;gt; writes to trigger file ||| server_handler() &amp;ndash;&amp;gt; trigger_file.changed ? run_action() : loop()&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The handler folder contains script files that make the necessary changes to the server file system.&lt;/p&gt;&#10;&lt;h2 id="implementation-shell-script-to-handle-the-update"&gt;Implementation: Shell script to handle the update&lt;/h2&gt;&#10;&lt;p&gt;In the previous article, I executed the command &lt;code&gt;touch server-config-update.txt&lt;/code&gt; in the &lt;em&gt;act_runner&lt;/em&gt; container to signal the presence of a new server configuration. On the server, I now use the following code to periodically check whether this file has changed.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;### set directories&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;actionfile&lt;span style="color:#f92672"&gt;=&lt;/span&gt;/opt/server-config/automation-hooks-handler/server-config-action.sh&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;triggerfile&lt;span style="color:#f92672"&gt;=&lt;/span&gt;/opt/server-config/automation-hooks-trigger/server-config-update.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;### Set initial time of file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;LTIME&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;stat -c %Z &lt;span style="color:#e6db74"&gt;${&lt;/span&gt;triggerfile&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;while&lt;/span&gt; true&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ATIME&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;stat -c %Z &lt;span style="color:#e6db74"&gt;${&lt;/span&gt;triggerfile&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;[[&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$ATIME&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; !&lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$LTIME&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;]]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;${&lt;/span&gt;actionfile&lt;span style="color:#e6db74"&gt;}&lt;/span&gt; 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; LTIME&lt;span style="color:#f92672"&gt;=&lt;/span&gt;$ATIME&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; sleep &lt;span style="color:#ae81ff"&gt;10&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The operating system is given the interpreter with which the script is to be executed via &lt;a href="https://en.wikipedia.org/wiki/Shebang_%28Unix%29" target="_blank" rel="noopener noreferrer" class="external-link"&gt;#!/bin/bash&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The command &lt;a href="https://wiki.ubuntuusers.de/stat/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;stat -c %Z&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; queries when the file was last changed and returns the time in &lt;a href="https://www.epochconverter.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Epoch format&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Finally, the time taken at the beginning of the script is compared with the time taken in the loop and if there is a change, the deploy routine that is still to be written can then run. The reference time is then updated.&#10;Finally, the script (&lt;code&gt;sleep 10&lt;/code&gt;) pauses for ten seconds before the query starts again. I use absolute paths so I&amp;rsquo;m able to both start it from console and per service &lt;code&gt;cron&lt;/code&gt; oder &lt;code&gt;systemd&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="first-test-and-hooking-into-autostart"&gt;First test and hooking into &amp;ldquo;autostart&amp;rdquo;&lt;/h3&gt;&#10;&lt;p&gt;Now we need to make the file executable for a first test:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# change file mode bits: add &amp;#34;executable&amp;#34; flag to server-config-handler script&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;chmod +x /opt/server-config/automation-hooks-handler/server-config-handler.sh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Next, I create the &lt;code&gt;server-config-action.sh&lt;/code&gt; file and add just an &lt;code&gt;echo&lt;/code&gt; command. To test the setup I create my update trigger file locally and start the script. I then modify the triggerfile in another shell using &lt;code&gt;touch server-config-update.txt&lt;/code&gt; and &lt;code&gt;--- CONFIG UPDATE TRIGGER detected ---&lt;/code&gt; appears in the console. Great!&lt;/p&gt;&#10;&lt;p&gt;Later on the server, I need to have the script run automatically after a restart. To do this, I use the &lt;code&gt;cron&lt;/code&gt; tool &lt;a href="https://wiki.ubuntuusers.de/Cron/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;help&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and create a new entry using &lt;code&gt;crontab -e&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# cron can automatically execute recurring tasks&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# in this case, we&amp;#39;re running server-config-handler script on reboot&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;@reboot sh /opt/server-config/automation-hooks-handler/server-config-handler.sh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With the command &lt;code&gt;ps aux&lt;/code&gt; I can now check whether the script is actually being executed:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;schallbert@server: ps aux&#10;[...]&#10;8:15 0:00 /bin/bash ./server-config-handler.sh&#10;8:15 0:00 sleep 10&#10;[...]&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="backup"&gt;Backup&lt;/h2&gt;&#10;&lt;p&gt;Making a backup of my applications and files before I roll out the update makes total sense. So I tell &lt;a href="https://blog.schallbert.de/en/server-protection/#regular-backups"&gt;Borg&lt;/a&gt; that I want to create a backup now. Of course, I have to stop all services first. This means that all data is accessible, coherent and static.&lt;/p&gt;&#10;&lt;h3 id="freeze-state-and-data"&gt;Freeze state and data&lt;/h3&gt;&#10;&lt;p&gt;To do this, I create a script in &lt;code&gt;automation-hooks-handler&lt;/code&gt; that automatically terminates all containers except &lt;em&gt;borg&lt;/em&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#! /bin/bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /opt/automation-hooks-handler/backup-pre-action.sh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# this shell script shuts down all docker containers prior to backup&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo &lt;span style="color:#e6db74"&gt;&amp;#34;Shutting down containers for backup:&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo &lt;span style="color:#e6db74"&gt;&amp;#34;watchtower...&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;cd /opt/watchtower&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker compose down 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The expression &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; means that any error output is redirected to the console. The number &lt;code&gt;1&lt;/code&gt; represents the file descriptor for &lt;code&gt;stdout&lt;/code&gt;, while &lt;code&gt;2&lt;/code&gt; means &lt;code&gt;stderr&lt;/code&gt;. The operator &lt;code&gt;&amp;gt;&amp;amp;&lt;/code&gt; functions as a &lt;code&gt;redirect merger&lt;/code&gt;. Later, we can go to this point and write the output to a log file - but I&amp;rsquo;ll leave that out for now for the sake of simplicity.&lt;/p&gt;&#10;&lt;h3 id="borgmatic-trigger-handler-mechanism-2-and-3"&gt;Borgmatic: Trigger handler mechanism #2 and #3&lt;/h3&gt;&#10;&lt;p&gt;I can now run the script in two ways:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;As a call by the &lt;code&gt;server-config-handler&lt;/code&gt; script described above&lt;/li&gt;&#10;&lt;li&gt;By the automation solution &lt;em&gt;borgmatic&lt;/em&gt; placed in front of &lt;em&gt;borg&lt;/em&gt;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;I choose the second option and therefore write the following commands in &lt;code&gt;borgmatic.d/config.yml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# borgmatic.d/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# List of one or more shell commands or scripts to execute before&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# creating a backup, run once per repository.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;before_backup&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;echo &amp;#34;Triggering container shutdown for backup.&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;touch /etc/automation-hooks-trigger/backup-pre.txt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;sleep 20&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;echo &amp;#34;Assuming container shutdown complete. Creating the backup now.&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;after_backup&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;echo &amp;#34;Triggering container restart after backup.&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;touch /etc/automation-hooks-trigger/backup-post.txt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;sleep 10&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;echo &amp;#34;Assuming container restart complete. Exiting.&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For this to work properly, I have to create a volume in the associated &lt;code&gt;docker-compose.yml&lt;/code&gt; and have it point to the path in the server&amp;rsquo;s file system: &lt;code&gt;${VOLUME_UPDATE_TRIGGER}:/etc/automation-hooks-trigger&lt;/code&gt;&#10;Now I set up the other side of these triggers: Handlers monitor trigger files for changes and call action scripts accordingly. These look very identical to &lt;code&gt;server-config-handler.sh&lt;/code&gt; except &lt;code&gt;actionfile&lt;/code&gt; and &lt;code&gt;triggerfile&lt;/code&gt; paths.&lt;/p&gt;&#10;&lt;h3 id="creating-the-backup"&gt;Creating the backup&lt;/h3&gt;&#10;&lt;p&gt;If I were to address &lt;em&gt;borg&lt;/em&gt; directly, the backup could be created using &lt;code&gt;create&lt;/code&gt;. To do this, I would have to specify in which repository the backup copy should be saved and under which name. In the example below, this is specified using the scope operator: &lt;code&gt;::config-update&lt;/code&gt;. The folders to be backed up are then specified.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;borg create /path/to/repo::config-update ~/opt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I use &lt;em&gt;borgmatic&lt;/em&gt;, which takes a lot of work off my hands using the configuration file. However, I have to execute the command in the container. To better check whether everything is working, I output statistics &amp;ldquo;verbose&amp;rdquo; to the console (&lt;code&gt;--stats -v 1&lt;/code&gt;) and display the copied files &lt;code&gt;--files&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker exec borgmatic sh -c &lt;span style="color:#e6db74"&gt;&amp;#34;cd &amp;amp;&amp;amp; borgmatic --stats -v 1 --files 2&amp;gt;&amp;amp;1&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I add this line to the automation script.&lt;/p&gt;&#10;&lt;h2 id="second-test-to-create-the-backup"&gt;Second test to create the backup&lt;/h2&gt;&#10;&lt;p&gt;If everything works now, the complete process looks like this:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;After the changed files have been received by Giteas Automation, &lt;em&gt;act_runner&lt;/em&gt; (in the Docker container) stores the files on the server and then sets the &lt;code&gt;server-config-update&lt;/code&gt; trigger.&lt;/li&gt;&#10;&lt;li&gt;Within &lt;code&gt;10sec&lt;/code&gt; the trigger is recognized by &lt;code&gt;server-config-handler.sh&lt;/code&gt;, which then calls &lt;code&gt;server-config-action.sh&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;borgmatic&lt;/em&gt; (in the Docker container) is instructed to create a backup. This in turn writes the &lt;code&gt;pre-backup&lt;/code&gt; trigger.&lt;/li&gt;&#10;&lt;li&gt;Again within &lt;code&gt;10sec&lt;/code&gt; this &lt;code&gt;pre-backup-handler.sh&lt;/code&gt; calls the &lt;code&gt;backup-pre-action.sh&lt;/code&gt; script and stops all containers except &lt;em&gt;borgmatic&lt;/em&gt;.&lt;/li&gt;&#10;&lt;li&gt;Due to the built-in delay, &lt;em&gt;borgmatic&lt;/em&gt; waits for this and then creates the backup.&lt;/li&gt;&#10;&lt;li&gt;After the backup, &lt;em&gt;borgmatic&lt;/em&gt; writes the &lt;code&gt;backup-post-action.sh&lt;/code&gt; trigger.&lt;/li&gt;&#10;&lt;li&gt;Within another &lt;code&gt;10 seconds&lt;/code&gt;, &lt;code&gt;post-backup-handler.sh&lt;/code&gt; recognizes the changed file and restarts all containers via &lt;code&gt;post-backup-actions.sh&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;em&gt;borgmatic&lt;/em&gt; tells &lt;code&gt;server-config-update.sh&lt;/code&gt; whether any errors have occurred anywhere in the process so far. If not, it continues.&lt;/li&gt;&#10;&lt;li&gt;All Docker containers are stopped.&lt;/li&gt;&#10;&lt;li&gt;The server configuration is rolled out to the appropriate locations.&lt;/li&gt;&#10;&lt;li&gt;All Docker containers are restarted with the new configuration.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;A first success: These scripts are already running on my laptop up to point 6:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;schallbert@laptop: touch server-config-update.txt&#10;server-config-handler@laptop: --- RUN backup ---&#10;borgmatic@docker: /etc/borgmatic.d/config.yml: Running 4 commands for pre-backup hook&#10; Triggering container shutdown for backup. &#10;backup-pre-handler.sh@laptop: --- RUN backup-pre-action.sh ---&#10; Shutting down containers for backup:&#10; watchtower...&#10; [...]&#10; complete.&#10;borgmatic@docker: Assuming container shutdown complete. Creating the backup now.&#10; local: Creating archive&#10; Failed to create/acquire the lock /mnt/repository/lock.exclusive&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="troubleshooting-for-failed-to-acquire-the-lock"&gt;Troubleshooting for &amp;ldquo;failed to acquire the lock&amp;rdquo;&lt;/h3&gt;&#10;&lt;p&gt;This problem occurs for me when &lt;em&gt;borg&lt;/em&gt; reports an error when creating a backup that causes the program to abort. In this case, the repository is apparently not released correctly, so that after restarting the container it remains reserved for the old, now non-existent container. The following command solves this problem:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker exec borgmatic sh -c &lt;span style="color:#e6db74"&gt;&amp;#34;cd &amp;amp;&amp;amp; borg break-lock /mnt/repository&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="transferring-the-configuration"&gt;Transferring the configuration&lt;/h3&gt;&#10;&lt;p&gt;Now the configuration files have to be copied to the correct location on the server. Fortunately, I had already cloned the target folder structure when creating the repository, so I should be able to do this with a single copy command without &amp;ldquo;hardcoding&amp;rdquo;. After a bit of online research and a look at the user manual for the copy command &lt;code&gt;man cp&lt;/code&gt;, I have my command:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# copy recursively contents of folder &amp;#34;server-config&amp;#34; to &amp;#34;/opt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;sudo cp -r -v /opt/server-config/. /opt 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This tells the operating system to copy the contents (&lt;code&gt;/.&lt;/code&gt;) of the folder &lt;code&gt;server-config&lt;/code&gt; recursively (&lt;code&gt;-r&lt;/code&gt;), including all subfolders into the folder &lt;code&gt;opt&lt;/code&gt;, which is located in the root directory &lt;code&gt;/&lt;/code&gt;. &lt;code&gt;cp&lt;/code&gt; works in an overwrite-supplement manner, so it will create files that do not yet exist and overwrite existing ones, and will not &amp;ldquo;copy them next to each other&amp;rdquo; under the same name. With the &lt;code&gt;-v&lt;/code&gt; option I can output additional details, and with &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; I redirect the error output to the console.&lt;/p&gt;&#10;&lt;p&gt;All folder indicators must be exactly where they are in the command: A slash after &lt;code&gt;/opt/&lt;/code&gt; would copy folders redundantly without overwriting, but would overwrite files. Without &lt;code&gt;.&lt;/code&gt; the folder &lt;code&gt;server-config&lt;/code&gt; would be created in the target path.&lt;/p&gt;&#10;&lt;h3 id="switching-to-rsync"&gt;Switching to rsync&lt;/h3&gt;&#10;&lt;p&gt;Unfortunately the &lt;code&gt;cp&lt;/code&gt; command also copies a few files that I don&amp;rsquo;t want copied: repository-specific folders such as &lt;code&gt;.gitea&lt;/code&gt;, or the folders for triggers and handlers. I only need these under &lt;code&gt;server-config&lt;/code&gt;, not directly in &lt;code&gt;opt&lt;/code&gt;. To fix this, I use the &lt;code&gt;rsync&lt;/code&gt; command instead. There I can use a &lt;code&gt;-u&lt;/code&gt; option so new files owerwrite older ones only and add &lt;code&gt;--exclude&lt;/code&gt; to exclude files and folders that should not be copied. This looks like so:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;rsync -r -u -v --exclude &lt;span style="color:#e6db74"&gt;&amp;#39;.*&amp;#39;&lt;/span&gt; --exclude &lt;span style="color:#e6db74"&gt;&amp;#39;README.md&amp;#39;&lt;/span&gt; --exclude &lt;span style="color:#e6db74"&gt;&amp;#39;&amp;lt;otherFolders&amp;gt;&amp;#39;&lt;/span&gt; /opt/server-config/. /opt 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But suddenly &lt;em&gt;gitea&lt;/em&gt; no longer starts. Error message:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;docker@server: [...] failed to load config file &amp;#34;app.ini&amp;#34;: open: permission denied&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After a long time of pondering and restarting the Docker client several times, I see that &lt;em&gt;rsync&lt;/em&gt; has written the permissions of the source file to the target file, which was not the case with &lt;em&gt;cp&lt;/em&gt; before: &lt;code&gt;-rw-------&lt;/code&gt;. Now I change this by running &lt;code&gt;chmod +r app.ini&lt;/code&gt;. Everything starts up again as usual! 🎉&lt;/p&gt;&#10;&lt;h3 id="restart-all-applications"&gt;Restart all applications&lt;/h3&gt;&#10;&lt;p&gt;Since I run everything on my server in Docker, two simple &lt;a href="https://docs.docker.com/reference/cli/docker/container/restart/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;commands&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; are sufficient:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker stop &lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;docker ps -a -q&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt; 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...roll out config changes...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker restart &lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;docker ps -a -q&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt; 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="the-finished-automation"&gt;The finished automation&lt;/h2&gt;&#10;&lt;p&gt;My script is now finished and simply calls the action script.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#! /bin/bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#/opt/automation-hooks-handler/server-config-handler.sh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;[[&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$ATIME&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; !&lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;$LTIME&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;]]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;then&lt;/span&gt; &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; echo &lt;span style="color:#e6db74"&gt;&amp;#34;--- CONFIG UPDATE TRIGGER detected ---&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ./server-config-action.sh 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; LTIME&lt;span style="color:#f92672"&gt;=&lt;/span&gt;$ATIME&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; sleep &lt;span style="color:#ae81ff"&gt;10&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Script &lt;code&gt;server-config-action&lt;/code&gt; then executes the above described actions:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#! /bin/bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#/opt/automation-hooks-handler/server-config-action.sh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo &lt;span style="color:#e6db74"&gt;&amp;#34;--- RUN backup ---&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker exec borgmatic sh -c &lt;span style="color:#e6db74"&gt;&amp;#34;cd &amp;amp;&amp;amp; borgmatic --stats -v 1 --files 2&amp;gt;&amp;amp;1&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo &lt;span style="color:#e6db74"&gt;&amp;#34;--- STOP all containers ---&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker stop &lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;docker ps -a -q&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt; 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo &lt;span style="color:#e6db74"&gt;&amp;#34;--- DEPLOY config ---&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;cp -r -v /opt/server-config/. /opt 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;echo &lt;span style="color:#e6db74"&gt;&amp;#34;--- RESTART all containers ---&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker restart &lt;span style="color:#66d9ef"&gt;$(&lt;/span&gt;docker ps -a -q&lt;span style="color:#66d9ef"&gt;)&lt;/span&gt; 2&amp;gt;&amp;amp;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In order for the whole thing to work reliably, the three handler scripts must run in the background:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;server-config-handler.sh&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;backup-pre-handler.sh&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;backup-post-handler.sh&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;They react to the respective triggers by &lt;em&gt;act_runner&lt;/em&gt; from Gitea or by &lt;em&gt;borgmatic&lt;/em&gt;. I will now expand the &lt;em&gt;crontab&lt;/em&gt; accordingly and then I am done with the task for now. The console output of the entire process looks like this:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;--- RUN backup ---&#10;/etc/borgmatic.d/config.yml: Running 4 commands for pre-backup hook&#10;Triggering container shutdown for backup.&#10;--- RUN backup-pre-action.sh ---&#10;Shutting down containers for backup:&#10;gitea... &#10;fail2ban...&#10;complete.&#10;Assuming container shutdown complete. Creating the backup now.&#10;local: Creating archive&#10;&amp;lt;borg archive stats&amp;gt;&#10;/etc/borgmatic.d/config.yml: Running 4 commands for post-backup hook&#10;Triggering container restart after backup.&#10;--- RUN backup-post-action.sh ---&#10;Restarting containers after backup:&#10;fail2ban...&#10;gitea... &#10;watchtower...&#10;complete.&#10;Assuming container restart complete. Exiting.&#10;local: Pruning archives&#10;local: Compacting segments&#10;compaction freed about 1.82 MB repository space.&#10;local: Running consistency checks&#10;summary:&#10;/etc/borgmatic.d/config.yml: Successfully ran configuration file&#10;--- STOP all containers ---&#10;&amp;lt;container ids&amp;gt;&#10;--- DEPLOY config ---&#10;sending incremental file list&#10;&amp;lt;files that are copied&amp;gt;&#10;sent 206,558 bytes received 3,463 bytes 420,042.00 bytes/sec&#10;total size is 193,167 speedup is 0.92&#10;--- RESTART all containers ---&#10;&amp;lt;container ids&amp;gt;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Great! Now all I need is for this log to be delivered to me if something goes wrong.&lt;/p&gt;&#10;</description></item><item><title>Server configuration with Git</title><link>https://blog.schallbert.de/en/server-config-version-control/</link><pubDate>Mon, 15 Jul 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/server-config-version-control/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-07-15-server-versioncontrol-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Diagram thumb for putting server config under version control"&#10; title="Server configuration with Git" /&gt;&#10;&lt;h2 id="what-is-it-about"&gt;What is it about?&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;I want version control for the configuration of server applications&lt;/li&gt;&#10;&lt;li&gt;Discussion of technical solutions for implementation&lt;/li&gt;&#10;&lt;li&gt;Tutorial: Exclude Docker/Gitea/other &amp;ldquo;secrets&amp;rdquo; from version control&lt;/li&gt;&#10;&lt;li&gt;Tutorial: Create a deployment pipeline&lt;/li&gt;&#10;&lt;li&gt;introduce automation trigger for subsequent rollout of files on the server&lt;/li&gt;&#10;&lt;li&gt;Next article: Integrate automation on the server and install upgrades&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="background"&gt;Background&lt;/h2&gt;&#10;&lt;p&gt;I have &lt;a href="https://blog.schallbert.de/en/server-auto-upgrade/"&gt;Watchtower running&lt;/a&gt; on &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;my server&lt;/a&gt; to automatically keep the installed distributions up to date and freshly patched. Recently I had a case where my &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/"&gt;Gitea Action Runner&lt;/a&gt; would no longer start automatically and even when started manually it gave an error message.&lt;/p&gt;&#10;&lt;p&gt;Docker had apparently &lt;a href="https://blog.schallbert.de/en/server-auto-upgrade/"&gt;updated itself&lt;/a&gt; without my supervision and was now throwing a volume error when starting up the runner container, which I had never seen before. The error message was clear and could easily be fixed with small changes in a configuration file for Gitea. Nevertheless, I now had the feeling that version control for my configuration would be useful for my future self in order to be able to better understand changes, updates and the reasons behind.&lt;/p&gt;&#10;&lt;h2 id="preliminary-considerations"&gt;Preliminary considerations&lt;/h2&gt;&#10;&lt;p&gt;It sounds like a circular reference to me: I record the configuration files for my server in Gitea, which itself runs on my server. This could become interesting with auto-deployment. But more on that later.&lt;/p&gt;&#10;&lt;p&gt;Two options spontaneously come to mind for getting version control with automatic synchronization:&lt;/p&gt;&#10;&lt;h3 id="1-hardlink"&gt;1. Hardlink&lt;/h3&gt;&#10;&lt;p&gt;This solution would store the configuration files scattered across many folders on my server in a folder declared as a repository using a &lt;a href="https://en.wikipedia.org/wiki/Hard_link" target="_blank" rel="noopener noreferrer" class="external-link"&gt;hardlink&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Why use a hardlink? Because every service on my server runs encapsulated in itself and has its own configuration files and environment variables stored together with the service. Without hardlinks, I would have to version the entire service folder and make my &lt;code&gt;.gitignore&lt;/code&gt; correspondingly complex.&lt;/p&gt;&#10;&lt;p&gt;With Git, I would version the files mapped via hardlinks and make their contents available on Gitea in this way.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&lt;code&gt;schallbert@server:/server-config-files&lt;/code&gt; &amp;ndash;&amp;gt; hardlinks &amp;ndash;&amp;gt; repository &amp;ndash;&amp;gt; Gitea&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;So I would get a downstream &amp;ldquo;version monitoring&amp;rdquo; with a backup copy in the sense that the files are now lying around multiple times.&lt;/p&gt;&#10;&lt;h3 id="2-repo-and-auto-deploy-to-the-server"&gt;2. Repo and auto-deploy to the server&lt;/h3&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-07-15-server-versioncontrol.jpg" alt="Image: Diagram how version control with auto-deploy might work on my server"&gt;&lt;/figure&gt;&#10;&lt;p&gt;In this scenario, I keep the configuration files locally on my laptop and can version them with Git as usual. On Gitea, I would map the repository, and at the end of the chain, my runner would have to auto-deploy on the server every time the configuration changes and then restart the affected containers.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&lt;code&gt;schallbert@laptop:/server-config-files&lt;/code&gt; &amp;ndash;&amp;gt; repository &amp;ndash;&amp;gt; Gitea &amp;ndash;&amp;gt; act-runner &amp;ndash;&amp;gt; &lt;code&gt;server:/&amp;lt;service1...ServiceN&amp;gt;/config-files&lt;/code&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;I would get the administration &amp;ldquo;main&amp;rdquo; on my laptop, and the server would follow the mapping on Gitea.&lt;/p&gt;&#10;&lt;h3 id="problems"&gt;Problems&lt;/h3&gt;&#10;&lt;p&gt;In both cases, I don&amp;rsquo;t have the option of testing changed configurations in advance. Everything I do goes straight to &amp;ldquo;Prod&amp;rdquo; and would be live. In the worst case, I can easily mess up my setup.&lt;/p&gt;&#10;&lt;p&gt;This doesn&amp;rsquo;t change the &lt;em&gt;status quo ante&lt;/em&gt;, where I updated the files directly on the server. So a new problem only in the sense that I didn&amp;rsquo;t have a Prod operation when I set it up in the first place and so there was no risk of failure.&lt;/p&gt;&#10;&lt;p&gt;Solution two seems to be more complex to implement, because I need a deployment pipeline that rolls out the files in the repository on my server. Especially since the runner is in a Docker container, while the configuration files are located directly in the server&amp;rsquo;s file system.&lt;/p&gt;&#10;&lt;p&gt;Direct access to the server file system &lt;a href="https://maze88.dev/docker-socket-from-within-containers.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;is technically possible&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, but according to my limited understanding it would mean &lt;a href="https://dev.to/pbnj/docker-security-best-practices-45ih" target="_blank" rel="noopener noreferrer" class="external-link"&gt;a large attack surface for all my services&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, whose configuration files could now be directly manipulated via the repository in Gitea.&lt;/p&gt;&#10;&lt;h3 id="selecting-my-solution"&gt;Selecting my solution&lt;/h3&gt;&#10;&lt;p&gt;Solution two at least places the immediate live problem on my laptop, so that I don&amp;rsquo;t have to mess around with the production system in the first step. I think it would be easier to set up an integration environment here with which I can check my configuration changes in advance. Since I have all of my services running in Docker, this could perhaps be solved quite easily.&lt;/p&gt;&#10;&lt;h2 id="lets-get-to-work"&gt;Let&amp;rsquo;s get to work&lt;/h2&gt;&#10;&lt;h3 id="create-a-configuration-repo"&gt;Create a configuration repo&lt;/h3&gt;&#10;&lt;p&gt;Okay, then the first step is to get the configuration files from the server. To do this, I use the file transfer command &lt;a href="https://manpages.debian.org/bookworm/openssh-client/scp.1.en.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;scp&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;: &lt;code&gt;scp server:/path/to/source path/to/target&lt;/code&gt; about a dozen times until I have caught all the files.&lt;/p&gt;&#10;&lt;p&gt;I set up the folder structure in this repo exactly as the files are on the server. I hope that this will make my life a little easier later.&lt;/p&gt;&#10;&lt;h3 id="secrets-in-docker-composeyml"&gt;Secrets in &lt;code&gt;docker-compose.yml&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;But what do I do with &amp;ldquo;secrets&amp;rdquo; in the configuration files? Private keys, registration tokens, hashes? I would rather not have them lying around more or less openly in the repository. When using &lt;a href="https://docs.docker.com/compose/compose-file/05-services/#env_file" target="_blank" rel="noopener noreferrer" class="external-link"&gt;docker-compose&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; it&amp;rsquo;s quite simple: I can store secret values in hidden files for environment variables and exclude them from Git tracking. In the simplest case, such files are simply called &lt;code&gt;.env&lt;/code&gt; and contain a list of environment variables in the style of&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;BORG_PASSPHRASE&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&amp;lt;redacted&amp;gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In the corresponding &lt;code&gt;docker-compose.yml&lt;/code&gt; I pull the variable from the &lt;code&gt;.env&lt;/code&gt; file as follows:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- BORG_PASSPHRASE&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;BORG_PASSPHRASE&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I make these changes locally on my laptop. I use &lt;code&gt;.gitignore&lt;/code&gt; to specify using &lt;code&gt;.*&lt;/code&gt; so hidden files and thus &lt;code&gt;.env&lt;/code&gt; should not be included in the repository. But how do I know whether the containers are still booting correctly?&lt;/p&gt;&#10;&lt;h3 id="secrets-in-giteas-appini"&gt;Secrets in Gitea&amp;rsquo;s &lt;code&gt;app.ini&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;With Gitea, I&amp;rsquo;ve had a much harder time storing secrets in files. The &lt;code&gt;app.ini&lt;/code&gt; is also written dynamically by Gitea, so the file looks a little different every time the service is restarted. After a long search, I found&#10;&lt;a href="https://github.com/go-gitea/gitea/issues/25034" target="_blank" rel="noopener noreferrer" class="external-link"&gt;this issue&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, in which a solution for storing secrets separately was sought and found.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The only thing that seems sensible to me is storing the values &lt;code&gt;INTERNAL_TOKEN&lt;/code&gt; and &lt;code&gt;SECRET_KEY&lt;/code&gt; separately.&lt;/li&gt;&#10;&lt;li&gt;The other two &lt;a href="https://docs.gitea.com/next/administration/config-cheat-sheet/#server-server" target="_blank" rel="noopener noreferrer" class="external-link"&gt;properties&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; &lt;code&gt;LFS_JWT_SECRET&lt;/code&gt; and &lt;code&gt;JWT_SECRET&lt;/code&gt; are automatically generated anyway and regularly overwritten.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;In my configuration I only use &lt;code&gt;INTERNAL_TOKEN&lt;/code&gt;. So I will copy it in plain text and without quotes into a hidden file (&lt;code&gt;.INTERNAL_TOKEN&lt;/code&gt;) and make it available to the container via a Docker volume:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Gitea&amp;#39;s docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./.INTERNAL_TOKEN:/run/secrets/INTERNAL_TOKEN:ro&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In the &lt;code&gt;app.ini&lt;/code&gt; it is now important to use the path specified in the compose file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Gitea&amp;#39;s app.ini&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;server&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;INTERNAL_TOKEN = /run/secrets/INTERNAL_TOKEN&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Not &lt;code&gt;INTERNAL_TOKEN_URI=/run/secrets/INTERNAL_TOKEN&lt;/code&gt; as stated in the issue linked above, because this creates an error in &lt;code&gt;V1.22.1&lt;/code&gt; I am currently using: &lt;code&gt;Unsupported URI-Scheme&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="a-very-rough-test"&gt;A very rough test&lt;/h3&gt;&#10;&lt;p&gt;To check whether the changed configuration files still work, I install &lt;code&gt;docker&lt;/code&gt; and &lt;code&gt;docker-compose&lt;/code&gt; on my laptop. Then I try to start the containers.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;# docker console log&#10;Error: Network &amp;#39;caddy-proxy&amp;#39; declared as external, but could not be found.&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Oh right, Docker is not yet configured here. So create the network: &lt;code&gt;sudo docker network create caddy-proxy&lt;/code&gt; and try again. The download of Gitea and its dependencies begins and the container starts - although not as I had imagined: The folder permissions within Docker are incorrect, meaning that neither Gitea nor Act-runner can access all the required files.&lt;/p&gt;&#10;&lt;p&gt;Nevertheless, I find the first error:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;# gitea container log&#10;WARNING: The GITEA_RUNNER_REGISTRATION_TOKEN variable is not set. Defaulting to a blank string.&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I had forgotten to put the token string in quotation marks.&lt;/p&gt;&#10;&lt;p&gt;So for a fully functional integration environment, I have to solve at least two more problems:&#10;Folder permissions for the &lt;code&gt;Main&lt;/code&gt; on my laptop must be set up in such a way that the &lt;code&gt;Docker&lt;/code&gt; user also has write permissions. A simple solution for now is to append a &lt;code&gt;:Z&lt;/code&gt; to the volumes in question and mark them as &lt;a href="https://docs.docker.com/reference/cli/docker/container/run/#volumes-from" target="_blank" rel="noopener noreferrer" class="external-link"&gt;private unshared&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&#10;Now the volume definition in &lt;code&gt;docker-compose.yml&lt;/code&gt; looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./gitea:/data:Z&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I need a second file for environment variables to redirect my services to &lt;code&gt;localhost&lt;/code&gt;. At least the service starts this way and I can see the log output. I can already spot most of the configuration errors.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;m not sure, but I might have additional problems with the &lt;code&gt;caddyserver&lt;/code&gt; such as certificate management, proxy settings and so on.&lt;/p&gt;&#10;&lt;h2 id="create-a-deploy-pipeline"&gt;Create a deploy pipeline&lt;/h2&gt;&#10;&lt;p&gt;Now it would be great if the files uploaded to the Gitea repo (and previously tested locally for functionality) would automatically find their way to my server. For this I could create another Docker volume where &lt;code&gt;act-runner&lt;/code&gt; would then put the data stored via &lt;code&gt;on:push&lt;/code&gt; trigger. They would then be available on my server.&lt;/p&gt;&#10;&lt;h3 id="setup"&gt;Setup&lt;/h3&gt;&#10;&lt;p&gt;If we remember &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/#action-volumes"&gt;my last attempts&lt;/a&gt; to provide artifacts on the server using &lt;code&gt;act_runner&lt;/code&gt;, we can use a large part of that for this task as well:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# deploy-to-server.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Workflow for saving the server&amp;#39;s config repo to the local disk system&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Upload-server-config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;run-name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;${{ gitea.actor }} uploads server configuration files&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;on&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;push&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;branches&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;main&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;jobs&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Deploy job&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;build&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;runs-on&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;ubuntu-latest&lt;/span&gt; &lt;span style="color:#75715e"&gt;# this is the &amp;#34;label&amp;#34; the runner will use and map to docker target OS&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# left: where the output will end up on disk, right: volume name inside container&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/opt/server-config:/workspace/schallbert/server-config/tmp&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;steps&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;CHECKOUT ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/checkout@v3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;with&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;path&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;./tmp&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;RUN FILE CHANGE TRIGGER ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: |&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; cd ./tmp/automation-hooks-trigger&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; touch server-config-update.txt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="strange-volume-errors"&gt;Strange volume errors&lt;/h3&gt;&#10;&lt;p&gt;But the road to this point was rocky. For a long time I had only specified &lt;code&gt;/server-config&lt;/code&gt; under &lt;code&gt;volumes:&lt;/code&gt; on the container side and not the working directory of the runner. Then the action runs through and all commands in the &lt;code&gt;#TEST&lt;/code&gt; section also work. But when I look on my server, the &lt;code&gt;server-config&lt;/code&gt; folder created by Docker remains empty.&lt;/p&gt;&#10;&lt;p&gt;I use the following debug code under &lt;code&gt;RUN FILE CHANGE TRIGGER&lt;/code&gt; to help me find the path errors:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# deploy-to-server.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;run&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;|&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;echo &amp;#34;hello world&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;touch updated.txt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;pwd&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;ls -al&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I use &lt;code&gt;echo&lt;/code&gt; to check whether my code is even being executed in the runner. The &lt;code&gt;touch&lt;/code&gt; command puts the current timestamp in the &lt;code&gt;updated.txt&lt;/code&gt; file so that I can later use this as a &amp;ldquo;hook&amp;rdquo; for further automation. &lt;code&gt;pwd&lt;/code&gt; shows me the active path within the runner so that I can correctly map the Docker volume to the server hard drive. &lt;code&gt;ls -al&lt;/code&gt; shows me whether the configuration files compiled in the &lt;code&gt;CHECKOUT&lt;/code&gt; step were written correctly.&lt;/p&gt;&#10;&lt;p&gt;This tells me that the volume path on the &amp;ldquo;right side&amp;rdquo; was wrong. I redirect it to the active directory of the runner:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# deploy-to-server.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# left: where the output will end up on disk, right: volume name inside container&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#ae81ff"&gt;/opt/server-config:/workspace/schallbert/server-config/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then I got the following to read:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;# gitea / act-runner console log&#10;failed to create container: &amp;#39;Error response from daemon: Duplicate mount point: /workspace/schallbert/server-config&amp;#39;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It seems that the runner automatically creates the &amp;ldquo;right side&amp;rdquo; of the mount point itself and therefore cannot be reassigned. Only by adding another path part, in my case &lt;code&gt;/tmp&lt;/code&gt; - see &lt;a href="https://blog.schallbert.de/en/server-config-version-control/#setup"&gt;above&lt;/a&gt; - I fix the error and the long-awaited folder &lt;code&gt;server-config&lt;/code&gt; finally appears on my server 😌 with the following content:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;schallbert@schallbert-ubuntu-:/opt/server-config# ls -al&#10;total 52&#10;drwxr-xr-x 9 root root 4096 Jul 12 15:38 .&#10;drwxr-xr-x 9 root root 4096 Jul 11 19:56 ..&#10;-rwxr-xr-x 1 root root 395 Jul 11 20:05 boot-after-backup.sh&#10;drwxr-xr-x 3 root root 4096 Jul 11 20:05 borgmatic&#10;drwxr-xr-x 2 root root 4096 Jul 11 20:05 caddy2&#10;drwxr-xr-x 3 root root 4096 Jul 11 20:05 fail2ban&#10;drwxr-xr-x 8 root root 4096 Jul 12 15:38 .git&#10;drwxr-xr-x 3 root root 4096 Jul 11 20:05 .gitea&#10;drwxr-xr-x 4 root root 4096 Jul 11 20:05 gitea&#10;-rw-r--r-- 1 root root 312 Jul 11 20:05 .gitignore&#10;-rw-r--r-- 1 root root 557 Jul 11 20:05 README.md&#10;-rwxr-xr-x 1 root root 371 Jul 11 20:05 shutdown-for-backup.sh&#10;-rw-r--r-- 1 root root 0 Jul 12 15:38 updated.txt&#10;drwxr-xr-x 2 root root 4096 Jul 11 20:05 watchtower&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="distributing-the-configuration-on-the-server"&gt;Distributing the configuration on the server&lt;/h2&gt;&#10;&lt;p&gt;Okay, that&amp;rsquo;s the first step. I now have a properly configured Git repository that shows my server configuration and can be maintained and at least rudimentarily tested from my laptop. I can also use an automatic &lt;code&gt;Action&lt;/code&gt; to store configuration updates on the server and write an update file with a timestamp.&lt;/p&gt;&#10;&lt;p&gt;Now the update has to be received on the server, distributed and the affected programs and services have to be restarted. But we&amp;rsquo;ll look at this in the article &lt;a href="https://blog.schallbert.de/en/server-config-deploy/"&gt;Roll out server configuration&lt;/a&gt;.&lt;/p&gt;&#10;</description></item><item><title>'redir' through Caddyserver</title><link>https://blog.schallbert.de/en/migrating-to-subdomain/</link><pubDate>Tue, 30 Jan 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/migrating-to-subdomain/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-01-30-migrating-to-subdomain-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Thumbnail of correctly rendered &amp;#39;&amp;#39;website under construction&amp;#39;&amp;#39; page on schallbert.de"&#10; title="&amp;#39;redir&amp;#39; through Caddyserver" /&gt;&#10;&lt;p&gt;When I moved my website from Github Pages to &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;my own server&lt;/a&gt;, I had some problems and unanswered questions regarding automatic redirects. My blog migrated from the root domain to a subdomain (blog.schallbert.de).&lt;/p&gt;&#10;&lt;h2 id="requirements-for-the-redirect"&gt;Requirements for the redirect&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Links already referencing to my blog articles under the old name, e.g. to &lt;a href="https://blog.schallbert.de/about/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;schallbert.de/about&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; should redirect correctly to the subdomain (no &lt;em&gt;404&lt;/em&gt;)&lt;/li&gt;&#10;&lt;li&gt;The root page under &lt;code&gt;schallbert.de&lt;/code&gt; should be fully usable&lt;/li&gt;&#10;&lt;li&gt;Data and files for domain and subdomains should be independent of each other&lt;/li&gt;&#10;&lt;li&gt;The web servers should be able to use different technologies for different subpages&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="implementation"&gt;Implementation&lt;/h2&gt;&#10;&lt;p&gt;At first I had thought that I could achieve the redirects in the DNS provider via &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;CNAME&lt;/code&gt; entries. However, I quickly realized that &lt;em&gt;a)&lt;/em&gt; I had no idea how DNS even works and &lt;em&gt;b)&lt;/em&gt; the right way is via the configuration of my web server.&lt;/p&gt;&#10;&lt;h3 id="first-attempt-redir-at-"&gt;First attempt: redir at &amp;lsquo;/&amp;rsquo;&lt;/h3&gt;&#10;&lt;p&gt;On my web server (Caddy), redirects are specified in the &lt;code&gt;Caddyfile&lt;/code&gt; via &lt;code&gt;redir&lt;/code&gt;. This can be done &lt;code&gt;permanently&lt;/code&gt; (as so-called http &lt;code&gt;301&lt;/code&gt;) or &lt;code&gt;temporarily&lt;/code&gt; via &lt;code&gt;302&lt;/code&gt;. Default is a temporary redirect.&lt;/p&gt;&#10;&lt;p&gt;My first thought was to leave &lt;code&gt;schallbert.de&lt;/code&gt; unmodified and forward everything after a possible &lt;code&gt;/&lt;/code&gt;. This would then look like this in the Caddyfile:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;schallbert.de {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Define webserver&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;root * /www/landing&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;encode gzip&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;file_server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Redir to subdomain if domain has a slash&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;@redirect path_regexp /*&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;redir @redirect https://blog.schallbert.de{uri}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As a result, Caddy simply forwarded everything to the subdomain. The catch was that the subdomain itself also contains slashes or a URI after the slash and is then forwarded yet again. Example:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;schallbert.de/post -&amp;gt; blog.schallbert.de/post -&amp;gt; blog.schallbert.de/post -&amp;gt; blog.schallbert.de/post -&amp;gt; [...]&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;This gave me a recursive endless redirect. I completely paralyzed my server with it 😅&lt;/p&gt;&#10;&lt;h3 id="second-attempt-filtering-via-regexp"&gt;Second attempt: Filtering via regexp&lt;/h3&gt;&#10;&lt;p&gt;I need better filtering so that only slashes with additional characters behind them are actually redirected and the forwarding cannot be recursive. Fortunately, I had help from a good friend, so that the following &lt;code&gt;Caddyfile&lt;/code&gt; was created a short time later:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;schallbert.de {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Define webserver&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;root * /www/landing&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;encode gzip&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;file_server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Redir to subdomain to maintain links for blog&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;@redirect path_regexp ^/[^/]+(/.*)?$&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;redir @redirect https://blog.schallbert.de{uri}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Regexp checks whether there is a slash after the domain URL and whether it is followed by at least one other character. The redirection now works as desired and my blog is still fully accessible under the old links.&lt;/p&gt;&#10;&lt;p&gt;Nevertheless, I was still not completely satisfied. Because when I created a nice &amp;ldquo;landing page&amp;rdquo; on my root domain, suddenly only the plain HTML was visible. No favicon, no images, no CSS.&lt;/p&gt;&#10;&lt;p&gt;A quick look at the developer options quickly brought the realization that paths of all assets that I had stored on my landing page, marked with &lt;code&gt;/&lt;/code&gt; in the folder structure, were forwarded to the subdomain. Where they are not located, of course.&lt;/p&gt;&#10;&lt;h3 id="third-attempt-regexp-plus-file-search"&gt;Third attempt: Regexp plus file search&lt;/h3&gt;&#10;&lt;p&gt;Fortunately, I&amp;rsquo;m not alone with this problem, so I found the solution in &lt;a href="https://caddy.community/t/redirect-if-file-not-present/7902" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Caddy&amp;rsquo;s forum&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;p&gt;With the &lt;code&gt;not file&lt;/code&gt; marker I can define redirects in &lt;code&gt;Caddyfile&lt;/code&gt; if resources on the current page cannot be found.&lt;/p&gt;&#10;&lt;p&gt;My solution now looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;schallbert.de {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;@filenotfound {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;path_regexp ^/[^/]+(/.*)?$&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;not file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# show Caddy where to find page resources&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;root * /www/landing&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;encode gzip&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;route {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Redirect to subdomain if article cannot found on root&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;redir @filenotfound https://blog.schallbert.de{uri}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# else, define webserver and show page&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;file_server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here it is important to define the page with &lt;code&gt;root * /www/&amp;lt;pagelocation&amp;gt;&lt;/code&gt; before creating the &lt;code&gt;route&lt;/code&gt;. Otherwise the assets will only become available after I have already forwarded them for lack of existing files.&lt;/p&gt;&#10;&lt;h2 id="success"&gt;Success!&lt;/h2&gt;&#10;&lt;p&gt;Now my construction site page is finally displayed correctly.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-01-30-migrating-to-subdomain.jpg" alt="Image: Correctly rendered &amp;#39;website under construction&amp;#39; page on schallbert.de"&gt;&lt;/figure&gt;&#10;</description></item><item><title>Automatic Upgrades for everything!</title><link>https://blog.schallbert.de/en/server-auto-upgrade/</link><pubDate>Sat, 20 Jan 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/server-auto-upgrade/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-01-20-server-auto-upgrade-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: unattended-upgrades and Watchtower keep my server and application packages up to date."&#10; title="Automatic Upgrades for everything!" /&gt;&#10;&lt;p&gt;As the title says, I would like to have security updates installed as soon as they are released. This applies to the Ubuntu on my cloud server as well as to all applications that I run in Docker. As I&amp;rsquo;m lazy, I don&amp;rsquo;t want to carry out these security updates manually.&lt;/p&gt;&#10;&lt;p&gt;For function updates and enhancements, however, it is still OK for me to intervene manually from time to time.&#10;Let&amp;rsquo;s start with the system upgrades.&lt;/p&gt;&#10;&lt;h2 id="security-updates-per-unattended-upgrades"&gt;Security updates per &lt;code&gt;unattended-upgrades&lt;/code&gt;&lt;/h2&gt;&#10;&lt;p&gt;Ubuntu supplies - as a short &lt;a href="https://askubuntu.com/questions/9/how-do-i-enable-automatic-updates" target="_blank" rel="noopener noreferrer" class="external-link"&gt;query reveals&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; - a configurable tool for automated upgrades called &lt;code&gt;unattended-upgrades&lt;/code&gt;. First, I check whether it is installed on my system:&lt;/p&gt;&#10;&lt;h3 id="installation"&gt;Installation&lt;/h3&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;schallbert:~# which unattended-upgrades&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/usr/bin/unattended-upgrades&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Great. Otherwise I&amp;rsquo;d install it with &lt;code&gt;apt install unattended-upgrades&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="configure-unattended-upgrades"&gt;configure unattended-upgrades&lt;/h3&gt;&#10;&lt;p&gt;The &lt;a href="https://wiki.debian.org/UnattendedUpgrades" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Debian Wiki&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to the rescue: Config files can be found at &lt;code&gt;/etc/apt/apt.conf.d&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;First I make sure that automatic upgrades are active. To do that I open &lt;code&gt;20auto-upgrades&lt;/code&gt; and check presence of following lines:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;APT::Periodic::Update-Package-Lists &lt;span style="color:#e6db74"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;APT::Periodic::Unattended-Upgrade &lt;span style="color:#e6db74"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Very well, so automatic package index updates and -upgrades are active. Now, I should set an update time and upgrade types to install. These can be configured within&lt;code&gt;50unattended-upgrades&lt;/code&gt;, mostly by removing commented-out lines.&lt;/p&gt;&#10;&lt;h3 id="upgrade-sources"&gt;Upgrade sources&lt;/h3&gt;&#10;&lt;p&gt;The first section of the configuration file is dedicated to the permitted sources for the updates. Of course, only trustworthy providers should be listed here. My configuration includes updates for my Linux distribution and security plus &lt;code&gt;ESM&lt;/code&gt; = &amp;ldquo;Enhanced Security Maintenance&amp;rdquo; updates. It looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Unattended-Upgrade::Allowed-Origins &lt;span style="color:#f92672"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;distro_id&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;:&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;distro_codename&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;distro_id&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;:&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;distro_codename&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;-security&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;distro_id&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;ESMApps:&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;distro_codename&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;-apps-security&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;distro_id&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;ESM:&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;distro_codename&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;-infra-security&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;}&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="updates-for-dev-releases-too"&gt;Updates for dev releases, too?&lt;/h3&gt;&#10;&lt;p&gt;I have the value set to &lt;code&gt;auto&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Unattended-Upgrade::DevRelease &lt;span style="color:#e6db74"&gt;&amp;#34;auto&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="automatic-reboot"&gt;Automatic reboot&lt;/h3&gt;&#10;&lt;p&gt;Some security updates (e.g. concerning the &lt;a href="https://en.wikipedia.org/wiki/Kernel_%28operating_system%29" target="_blank" rel="noopener noreferrer" class="external-link"&gt;kernel&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;) require the machine to be restarted. They will therefore only take effect if you have set the corresponding values in the file. &lt;code&gt;WithUsers&lt;/code&gt; is a matter of taste, because you are force-logged off if by chance an update has just been installed that requires a restart. To practically rule this out, I have entered a time under &lt;code&gt;Reboot-Time&lt;/code&gt; when I am certainly not working with the machine.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Unattended-Upgrade::Automatic-Reboot &lt;span style="color:#e6db74"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Unattended-Upgrade::Automatic-Reboot-WithUsers &lt;span style="color:#e6db74"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Unattended-Upgrade::Automatic-Reboot-Time &lt;span style="color:#e6db74"&gt;&amp;#34;03:45&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Of course, automatic reboot has repercussions for all the programs I use. I therefore make sure in my &lt;code&gt;docker-compose.yml&lt;/code&gt; files that &lt;code&gt;always&lt;/code&gt; or &lt;code&gt;unless-stopped&lt;/code&gt; is really noted in &lt;code&gt;restart:&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="notifications"&gt;Notifications&lt;/h3&gt;&#10;&lt;p&gt;I haven&amp;rsquo;t switched on automatic mails or other push notifications for the time being, as I&amp;rsquo;m still in test mode and don&amp;rsquo;t really want to receive a lot more messages. As soon as I change this, there will definitely be an update here.&lt;/p&gt;&#10;&lt;h3 id="function-test"&gt;Function test&lt;/h3&gt;&#10;&lt;p&gt;To see if unattended-upgrades does what it is supposed to do, I take a look at the logs under &lt;code&gt;/var/log/unattended-upgrades&lt;/code&gt;.&#10;The file &lt;code&gt;unattended-upgrades.log&lt;/code&gt; contains the following, for example:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ruby" data-lang="ruby"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;06&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;59&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;781&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Starting&lt;/span&gt; unattended upgrades script&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;06&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;59&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;781&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Allowed&lt;/span&gt; origins &lt;span style="color:#e6db74"&gt;are&lt;/span&gt;: o&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;Ubuntu&lt;/span&gt;,a&lt;span style="color:#f92672"&gt;=&lt;/span&gt;jammy, o&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;Ubuntu&lt;/span&gt;,a&lt;span style="color:#f92672"&gt;=&lt;/span&gt;jammy&lt;span style="color:#f92672"&gt;-&lt;/span&gt;security, o&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;UbuntuESMApps&lt;/span&gt;,a&lt;span style="color:#f92672"&gt;=&lt;/span&gt;jammy&lt;span style="color:#f92672"&gt;-&lt;/span&gt;apps&lt;span style="color:#f92672"&gt;-&lt;/span&gt;security, o&lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;06&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;59&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;781&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Initial&lt;/span&gt; &lt;span style="color:#e6db74"&gt;blacklist&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;06&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;59&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;782&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Initial&lt;/span&gt; whitelist (&lt;span style="color:#f92672"&gt;not&lt;/span&gt; strict):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;06&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;14&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;33&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;956&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Packages&lt;/span&gt; that will be &lt;span style="color:#e6db74"&gt;upgraded&lt;/span&gt;: libc&lt;span style="color:#f92672"&gt;-&lt;/span&gt;bin libc&lt;span style="color:#f92672"&gt;-&lt;/span&gt;dev&lt;span style="color:#f92672"&gt;-&lt;/span&gt;bin libc&lt;span style="color:#f92672"&gt;-&lt;/span&gt;devtools libc6 libc6&lt;span style="color:#f92672"&gt;-&lt;/span&gt;dev locales python3&lt;span style="color:#f92672"&gt;-&lt;/span&gt;twisted&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;06&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;14&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;33&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;956&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Writing&lt;/span&gt; dpkg log to &lt;span style="color:#e6db74"&gt;/var/&lt;/span&gt;log&lt;span style="color:#f92672"&gt;/&lt;/span&gt;unattended&lt;span style="color:#f92672"&gt;-&lt;/span&gt;upgrades&lt;span style="color:#f92672"&gt;/&lt;/span&gt;unattended&lt;span style="color:#f92672"&gt;-&lt;/span&gt;upgrades&lt;span style="color:#f92672"&gt;-&lt;/span&gt;dpkg&lt;span style="color:#f92672"&gt;.&lt;/span&gt;log&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;06&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;14&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;54&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;578&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;All&lt;/span&gt; upgrades installed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;However, the file &lt;code&gt;unattended-upgrades-shutdown.log&lt;/code&gt; is still empty. I will check at a later date whether everything works here too.&lt;/p&gt;&#10;&lt;h2 id="automate-container-updates-with-watchtower"&gt;Automate container updates with &lt;code&gt;Watchtower&lt;/code&gt;&lt;/h2&gt;&#10;&lt;p&gt;Now to the updates of my applications. A quick chat with a couple of admins revealed this:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;It is tedious to keep all programs up to date manually&lt;/li&gt;&#10;&lt;li&gt;For containerized applications, there are services that solve this task centrally&lt;/li&gt;&#10;&lt;li&gt;There have already been bad experiences when the latest release is referenced in &lt;code&gt;docker-compose.yml&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;With &lt;code&gt;image: &amp;lt;application&amp;gt;:latest&lt;/code&gt;, not all applications are stable or create problems with dependencies&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;One of these solutions is offered by &lt;a href="https://containrrr.dev/watchtower/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Watchtower&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, which again runs in a container.&lt;/p&gt;&#10;&lt;h3 id="installation-1"&gt;Installation&lt;/h3&gt;&#10;&lt;p&gt;As usual I create a &lt;code&gt;docker-compose.yml&lt;/code&gt; in a new folder at &lt;code&gt;opt/watchtower&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# copied from https://containrrr.dev/watchtower/notifications/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# watchtower/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;version&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;3&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;services&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;watchtower&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;containrrr/watchtower&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;restart&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;unless-stopped&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Watchtower needs &lt;code&gt;docker.sock&lt;/code&gt; to pull the updates and apply them to the existing containers.&#10;Apart from that, I have done nothing else for the time being. No notifications (for the reasons mentioned above), no logs, nothing. I hope this setup stays functional and I don&amp;rsquo;t have to post an &lt;em&gt;update&lt;/em&gt; here anytime soon.&lt;/p&gt;&#10;&lt;aside class="update-box update-box--warn" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ⚠️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Update: (haha) new container source für watchtower&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-02-20T00:00:00Z"&gt;&#10; 2026-02-20&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; The Watchtower solution from &lt;em&gt;containrrr&lt;/em&gt; no longer works on my system. It seems to have been discontinued. So I&amp;rsquo;m changing the &lt;code&gt;docker-compose.yml&lt;/code&gt; to: &lt;code&gt;image: nickfedor/watchtower:latest&lt;/code&gt;. In the long run, however, passing the Docker socket to a container is &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#rule-1-do-not-expose-the-docker-daemon-socket-even-to-the-containers" target="_blank" rel="noopener noreferrer" class="external-link"&gt;not a good idea at all&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. I&amp;rsquo;ve already made a note to find a less vulnerable solution.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Update: Watchtower decommissioned&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-11-01T00:00:00Z"&gt;&#10; 2026-11-01&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; I am now updating my containers unattended via script. A how-to can be found in my &lt;a href="https://blog.schallbert.de/en/auto-upgrade-docker-images/"&gt;auto-upgrade docker images&lt;/a&gt; post.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;p&gt;Further steps for a validation of Watchtower&amp;rsquo;s functions, for example, can be found &lt;a href="https://www.digitalocean.com/community/tutorials/how-to-automatically-update-docker-container-images-with-watchtower-on-ubuntu-22-04" target="_blank" rel="noopener noreferrer" class="external-link"&gt;in a tutorial by DigitalOcean&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="procedure"&gt;Procedure&lt;/h3&gt;&#10;&lt;p&gt;Watchtower checks the installed Docker images for updates on a daily basis &lt;a href="https://containrrr.dev/watchtower/introduction/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;according to the documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. If there is a new image release, Watchtower sends a &lt;code&gt;SIGTERM&lt;/code&gt; signal to the containers to be updated, whereupon they shut down (&lt;a href="https://containrrr.dev/watchtower/stop-signals/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;source&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;). The containers are then started up again.&lt;/p&gt;&#10;&lt;h3 id="involuntary-functional-test"&gt;Involuntary functional test&lt;/h3&gt;&#10;&lt;p&gt;A few days after starting the container, I dialed into my server again and looked through some logs at random. I just wanted to check what was going on.&lt;/p&gt;&#10;&lt;p&gt;In the &lt;code&gt;fail2ban&lt;/code&gt; log files I suddenly saw pages and pages of entries at a certain point in time, &lt;code&gt;14:42:56&lt;/code&gt;. More than I had seen in sum for days before. When I scrolled up to the beginning of this chain of entries, I saw that fail2ban had probably stopped for a moment.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt; 2024-01-26 14:42:50,817 &amp;lt;container_id&amp;gt; INFO Shutdown in progress...&#10; 2024-01-26 14:42:50,817 &amp;lt;container_id&amp;gt; INFO Observer stop ... try to end queue 5 seconds&#10; 2024-01-26 14:42:50,837 &amp;lt;daemon_id&amp;gt; INFO Observer stopped, 0 events remaining.&#10; 2024-01-26 14:42:50,878 &amp;lt;container_id&amp;gt; INFO Stopping all jails&#10; 2024-01-26 14:42:50,879 &amp;lt;container_id&amp;gt; INFO Removed logfile: &amp;#39;/var/log/auth.log&amp;#39;&#10; 2024-01-26 14:42:50,879 &amp;lt;container_id&amp;gt; INFO Removed logfile: &amp;#39;/var/log/caddy2/gitea_access.log&amp;#39;&#10; 2024-01-26 14:42:50,879 &amp;lt;container_id&amp;gt; INFO Removed logfile: &amp;#39;/var/log/caddy2/server_access.log&amp;#39;&#10; 2024-01-26 14:42:51,052 &amp;lt;daemon_id&amp;gt; NOTIC [sshd] Flush ticket(s) with iptables&#10; 2024-01-26 14:42:51,063 &amp;lt;container_id&amp;gt; INFO Jail &amp;#39;sshd&amp;#39; stopped&#10; 2024-01-26 14:42:51,137 &amp;lt;daemon_id&amp;gt; NOTIC [caddy-status] Flush ticket(s) with iptables-multiport&#10; 2024-01-26 14:42:51,137 &amp;lt;container_id&amp;gt; INFO Jail &amp;#39;caddy-status&amp;#39; stopped&#10; 2024-01-26 14:42:51,138 &amp;lt;container_id&amp;gt; INFO Connection to database closed.&#10; 2024-01-26 14:42:51,139 &amp;lt;container_id&amp;gt; INFO Exiting Fail2ban&#10; 2024-01-26 14:42:56,173 &amp;lt;new_container_id&amp;gt; INFO --------------------------------------------------&#10; 2024-01-26 14:42:56,173 &amp;lt;new_container_id&amp;gt; INFO Starting Fail2ban v1.0.2&#10; 2024-01-26 14:42:56,173 &amp;lt;new_container_id&amp;gt; INFO Observer start...&#10;[...]&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;All the log entries at the same time start at the end of the section shown above. A whole bunch of IP addresses are loaded there in preparation to be banned.&#10;I looked at the Ubuntu syslog, slightly worried:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;Jan 26 14:42:55 schallbert systemd[1]: docker-&amp;lt;ID&amp;gt;.scope: Deactivated successful&#10;Jan 26 14:42:55 schallbert systemd[1]: docker-&amp;lt;ID&amp;gt;.scope: Consumed 21:05 CPU time&#10;Jan 26 14:42:55 schallbert dockerd[690]: time=&amp;#34;2024-01-26T14:42:55.228607617Z&amp;#34; level=info msg=&amp;#34;ignoring event&amp;#34; &#10;Jan 26 14:42:55 schallbert containerd[639]: time=&amp;#34;2024-01-26T14:42:55.229864632Z&amp;#34; level=info msg=&amp;#34;shim disconnected&amp;#34; &#10;Jan 26 14:42:55 schallbert containerd[639]: time=&amp;#34;2024-01-26T14:42:55.230125409Z&amp;#34; level=warning msg=&amp;#34;cleaning up after shim disconnected&amp;#34; i&amp;gt;&#10;Jan 26 14:42:55 schallbert containerd[639]: time=&amp;#34;2024-01-26T14:42:55.230145478Z&amp;#34; level=info msg=&amp;#34;cleaning up dead shim&amp;#34;&#10;Jan 26 14:42:55 schallbert containerd[639]: time=&amp;#34;2024-01-26T14:42:55.240586557Z&amp;#34; level=warning msg=&amp;#34;cleanup warnings time=\&amp;#34;2024-01-26T14:&amp;gt;&#10;Jan 26 14:42:55 schallbert dockerd[690]: time=&amp;#34;2024-01-26T14:42:55.241839655Z&amp;#34; level=warning msg=&amp;#34;ShouldRestart failed&#10;Jan 26 14:42:55 schallbert containerd[639]: time=&amp;#34;2024-01-26T14:42:55.870912387Z&amp;#34; level=info msg=&amp;#34;loading plugin \&amp;#34;io.containerd.event.v1.p&amp;gt;&#10;Jan 26 14:42:55 schallbert containerd[639]: time=&amp;#34;2024-01-26T14:42:55.870997689Z&amp;#34; level=info msg=&amp;#34;loading plugin \&amp;#34;io.containerd.internal.v&amp;gt;&#10;Jan 26 14:42:55 schallbert containerd[639]: time=&amp;#34;2024-01-26T14:42:55.871007368Z&amp;#34; level=info msg=&amp;#34;loading plugin \&amp;#34;io.containerd.ttrpc.v1.t&amp;gt;&#10;Jan 26 14:42:55 schallbert containerd[639]: time=&amp;#34;2024-01-26T14:42:55.871150831Z&amp;#34; level=info msg=&amp;#34;starting signal loop&amp;#34; namespace=moby path&amp;gt;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Hm, &lt;code&gt;cleaning up dead shim&lt;/code&gt;. That sounds ominous. A quick &lt;a href="https://iximiuz.com/en/posts/implementing-container-runtime-shim/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;search&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; shows that the shim forms an intermediate layer between the container manager and the container itself and forwards the container&amp;rsquo;s input and output. Its runtime is linked to that of the container.&lt;/p&gt;&#10;&lt;p&gt;In other words, I saw a normal shutdown followed by a restart. Fortunately, no break-in!&#10;All that remains is to find out the reason for the restart of Fail2ban. This reminds me that I don&amp;rsquo;t even know when Watchtower runs the updates&amp;hellip;&lt;/p&gt;&#10;&lt;p&gt;So I quickly looked in the Watchtower logs with &lt;code&gt;docker container logs &amp;lt;container_id&amp;gt;&lt;/code&gt; and behold:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;time=&amp;#34;2024-01-26T14:42:47Z&amp;#34; level=info msg=&amp;#34;Found new lscr.io/linuxserver/fail2ban:latest image (9bda077d765e)&amp;#34;&#10;time=&amp;#34;2024-01-26T14:42:50Z&amp;#34; level=info msg=&amp;#34;Stopping /fail2ban (e0317a105d53) with SIGTERM&amp;#34;&#10;time=&amp;#34;2024-01-26T14:42:55Z&amp;#34; level=info msg=&amp;#34;Creating /fail2ban&amp;#34;&#10;time=&amp;#34;2024-01-26T14:42:55Z&amp;#34; level=info msg=&amp;#34;Session done&amp;#34; Failed=0 Scanned=6 Updated=1 notify=no&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now I have proof: Watchtower does what it is supposed to do. It upgrades containers to a new version as soon as updates become available. Great!&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;PS:&lt;/strong&gt; Since I haven&amp;rsquo;t configured Watchtower very much, I assume from the timestamps in the logs that the updates are run daily at the time Watchtower is started. I wonder whether I should switch to a certain version of the image instead of using &lt;code&gt;latest&lt;/code&gt;, though.&lt;/p&gt;&#10;</description></item><item><title>Better security for my server</title><link>https://blog.schallbert.de/en/server-protection/</link><pubDate>Fri, 12 Jan 2024</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/server-protection/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2024-01-12-server-protection-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: side-by-side image of fail2ban and borgmatic logos"&#10; title="Better security for my server" /&gt;&#10;&lt;h2 id="motivation"&gt;Motivation&lt;/h2&gt;&#10;&lt;p&gt;OK. I have my server setup, created an &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/"&gt;automatic build pipeline&lt;/a&gt; for my Blog and &lt;a href="https://blog.schallbert.de/en/self-hosted-jekyll-page-broken-links/"&gt;my website is displaying the Blog&lt;/a&gt; as it should. Still, I&amp;rsquo;m not completely done yet. Because I want to do more for server-side security than deactivating password logins.&lt;/p&gt;&#10;&lt;p&gt;And I don&amp;rsquo;t have backups. That&amp;rsquo;s never good, so let&amp;rsquo;s get something done about that.&lt;/p&gt;&#10;&lt;h2 id="locking-away-unwanted-guests"&gt;Locking away unwanted guests&lt;/h2&gt;&#10;&lt;p&gt;In my SSH-log of my machine&amp;rsquo;s root system, I get a lot of failed authentications:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ruby" data-lang="ruby"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;19&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;29&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93842&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;Invalid&lt;/span&gt; user admin from &lt;span style="color:#ae81ff"&gt;41&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;207&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;248&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;204&lt;/span&gt; port &lt;span style="color:#ae81ff"&gt;37194&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;19&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;29&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93842&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: pam_unix(&lt;span style="color:#e6db74"&gt;sshd&lt;/span&gt;:auth): check pass; user unknown&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;19&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;29&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93842&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: pam_unix(&lt;span style="color:#e6db74"&gt;sshd&lt;/span&gt;:auth): authentication failure; logname&lt;span style="color:#f92672"&gt;=&lt;/span&gt; uid&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt; euid&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt; tty&lt;span style="color:#f92672"&gt;=&lt;/span&gt;ssh ru&lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;19&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;31&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93842&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;Failed&lt;/span&gt; password &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; invalid user admin from &lt;span style="color:#ae81ff"&gt;41&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;207&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;248&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;204&lt;/span&gt; port &lt;span style="color:#ae81ff"&gt;37194&lt;/span&gt; ssh2&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;19&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;32&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93842&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;Connection&lt;/span&gt; closed by invalid user admin &lt;span style="color:#ae81ff"&gt;41&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;207&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;248&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;204&lt;/span&gt; port &lt;span style="color:#ae81ff"&gt;37194&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;preauth&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;14&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93886&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;Invalid&lt;/span&gt; user svn from &lt;span style="color:#ae81ff"&gt;84&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;108&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;40&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;27&lt;/span&gt; port &lt;span style="color:#ae81ff"&gt;44968&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;14&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93886&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: pam_unix(&lt;span style="color:#e6db74"&gt;sshd&lt;/span&gt;:auth): check pass; user unknown&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;14&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93886&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: pam_unix(&lt;span style="color:#e6db74"&gt;sshd&lt;/span&gt;:auth): authentication failure; logname&lt;span style="color:#f92672"&gt;=&lt;/span&gt; uid&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt; euid&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt; tty&lt;span style="color:#f92672"&gt;=&lt;/span&gt;ssh ru&lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;16&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93886&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;Failed&lt;/span&gt; password &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; invalid user svn from &lt;span style="color:#ae81ff"&gt;84&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;108&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;40&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;27&lt;/span&gt; port &lt;span style="color:#ae81ff"&gt;44968&lt;/span&gt; ssh2&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;16&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93886&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;Received&lt;/span&gt; disconnect from &lt;span style="color:#ae81ff"&gt;84&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;108&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;40&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;27&lt;/span&gt; port &lt;span style="color:#ae81ff"&gt;44968&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;Bye&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Bye&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;preauth&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;Jan&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;00&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;16&lt;/span&gt; sshd&lt;span style="color:#f92672"&gt;[&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;93886&lt;/span&gt;&lt;span style="color:#f92672"&gt;]&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;Disconnected&lt;/span&gt; from invalid user svn &lt;span style="color:#ae81ff"&gt;84&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;108&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;40&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;27&lt;/span&gt; port &lt;span style="color:#ae81ff"&gt;44968&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;preauth&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If I didn&amp;rsquo;t know that this is typical &amp;ldquo;internet noise&amp;rdquo;, I&amp;rsquo;d be nervous. Isn&amp;rsquo;t it coming close to thieves trying different keys at our front doors every few seconds? So what can we do about it? Ban them.&lt;/p&gt;&#10;&lt;h3 id="fail2ban"&gt;fail2ban&lt;/h3&gt;&#10;&lt;p&gt;That&amp;rsquo;s exactly what &lt;a href="https://github.com/fail2ban/fail2ban" target="_blank" rel="noopener noreferrer" class="external-link"&gt;fail2ban&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; can do for me.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt; __ _ _ ___ _ &#10; / _|__ _(_) |_ ) |__ __ _ _ _ &#10; | _/ _` | | |/ /| &amp;#39;_ \/ _` | &amp;#39; \ &#10; |_| \__,_|_|_/___|_.__/\__,_|_||_|&#10; v1.1.0.dev1 20??/??/??&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In short, &lt;code&gt;fail2ban&lt;/code&gt; scans access/auth logs&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt; for IP-addresses causing multiple failed authentications, and bans them when surpassing a user-defined threshold within a defined period of time.&lt;/p&gt;&#10;&lt;p&gt;How does fail2ban work? It modifies &lt;a href="https://en.wikipedia.org/wiki/Iptables" target="_blank" rel="noopener noreferrer" class="external-link"&gt;iptables&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, thus accesses packet filter rules (buzzword Firewall) on the network layer. So incoming requests of already blocked IP-addresses will not even reach my applications&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&#10;&lt;h3 id="install-fail2ban"&gt;install fail2ban&lt;/h3&gt;&#10;&lt;p&gt;&lt;code&gt;fail2ban&lt;/code&gt; seems to be close to an industry standard for blocking unwanted access requests on Linux. Every hobbyist admin I know is using it.&lt;/p&gt;&#10;&lt;p&gt;For installation and configuration of fail2ban there&amp;rsquo;s a ton of guides out there plus the (well-written) on inside its Github-repository. I won&amp;rsquo;t be pressing the point here.&lt;/p&gt;&#10;&lt;p&gt;I chose an installation as Docker container to get all dependencies auto-delivered as well. I&amp;rsquo;m using the distribution by &lt;a href="https://docs.linuxserver.io/images/docker-fail2ban/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;linuxserver&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and write the following &lt;code&gt;docker-compose.yml&lt;/code&gt; to have it deployed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /fail2ban/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;version&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;2.1&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;services&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;fail2ban&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;lscr.io/linuxserver/fail2ban:latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container_name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;fail2ban&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;cap_add&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;NET_ADMIN&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;NET_RAW&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;network_mode&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;host&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;environment&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;PUID=1000&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;PGID=1000&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;TZ=Etc/UTC&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;VERBOSITY=-vv&lt;/span&gt; &lt;span style="color:#75715e"&gt;#optional&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./config:/config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/var/log/auth.log:/var/log/auth.log:ro&lt;/span&gt; &lt;span style="color:#75715e"&gt;# host ssh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/var/log/caddy2:/var/log/caddy2:ro &lt;/span&gt; &lt;span style="color:#75715e"&gt;# gitea via caddy, caddyserver&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;restart&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;unless-stopped&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;What I add to add here: &lt;code&gt;fail2ban&lt;/code&gt; requires the Access logs as Volume (above added with &lt;code&gt;:ro&lt;/code&gt; as read-only volumes). A lot of filter rules come predefined in its &lt;code&gt;config&lt;/code&gt; folder, I just had to add a modified &lt;code&gt;jail.local&lt;/code&gt; &lt;a href="https://github.com/linuxserver/fail2ban-confs" target="_blank" rel="noopener noreferrer" class="external-link"&gt;inspiration: &lt;code&gt;linuxserver/fail2ban-confs&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and add a rule in &lt;code&gt;filter.d&lt;/code&gt; for caddy &lt;a href="https://muetsch.io/how-to-integrate-caddy-with-fail2ban.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;pulled from muetsch.io&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to complete my door watch.&lt;/p&gt;&#10;&lt;h3 id="fail2ban-example"&gt;fail2ban example&lt;/h3&gt;&#10;&lt;p&gt;This is how a fail2ban-log looks like for my SSH-Daemon (sshd):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ruby" data-lang="ruby"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# schallbert:/opt/fail2ban/config/log/fail2ban# grep &amp;#34;220.124.89.47&amp;#34; fail2ban.log &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;600&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;7&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;FBB3630BB38&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;sshd&lt;span style="color:#f92672"&gt;]&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Found&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;220&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;124&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;89&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;47&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;23&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;003&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;7&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;FBB3630BB38&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;sshd&lt;span style="color:#f92672"&gt;]&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Found&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;220&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;124&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;89&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;47&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;22&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;25&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;205&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;7&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;FBB3630BB38&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;sshd&lt;span style="color:#f92672"&gt;]&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Found&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;220&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;124&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;89&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;47&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;24&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;27&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;206&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;7&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;FBB3630BB38&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;sshd&lt;span style="color:#f92672"&gt;]&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Found&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;220&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;124&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;89&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;47&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;26&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;32&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;610&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;7&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;FBB3630BB38&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;INFO&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;sshd&lt;span style="color:#f92672"&gt;]&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Found&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;220&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;124&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;89&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;47&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;32&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;2024&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;01&lt;/span&gt;&lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;11&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;17&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;:&lt;span style="color:#ae81ff"&gt;33&lt;/span&gt;,&lt;span style="color:#ae81ff"&gt;045&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;7&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;FBB36104B38&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;NOTIC&lt;/span&gt; &lt;span style="color:#f92672"&gt;[&lt;/span&gt;sshd&lt;span style="color:#f92672"&gt;]&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;Ban&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;220&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;124&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;89&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;47&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;kbye!&lt;/p&gt;&#10;&lt;h3 id="what-does-not-yet-work-gitea--fail2ban"&gt;What does not (yet) work: Gitea &amp;amp; fail2ban&lt;/h3&gt;&#10;&lt;p&gt;Also on my gitea instance, I get quite some authentication requests that I&amp;rsquo;d like to block. Unfortunately, I cannot get Gitea to put the registered accesses into a log file. I see them in the console only. On the other hand, the shell access to Gitea is already secured as it is channeled through &lt;code&gt;sshd&lt;/code&gt; that also guarantees remote access to my server.&lt;/p&gt;&#10;&lt;p&gt;What is missing is a ban for failed web login requests.&lt;/p&gt;&#10;&lt;p&gt;I thought I had configured &lt;code&gt;app.ini&lt;/code&gt; to get access logs written to file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/conf/app.ini&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[log]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;MODE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;LEVEL&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;warn&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ROOT_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLE_ACCESS_LOGS&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLE_SSH_LOG&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;logger.access.MODE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;access-file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[log.access-file]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;MODE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ACCESS&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;LEVEL&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;info&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;FILE_NAME&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;access.log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I have both activated the logs with &lt;code&gt;ENABLE_ACCESS_LOGS&lt;/code&gt; and configured log-to-file. &lt;code&gt;access.log&lt;/code&gt; is being generated but no authentication tries are being written to it. Maybe caddy&amp;rsquo;s reverse proxy snitches them away before they reach Gitea? I&amp;rsquo;m sure I&amp;rsquo;ll find out at a later point in time.&lt;/p&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; testing fail2ban with ipdables and banip&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2024-08-22T00:00:00Z"&gt;&#10; 2024-08-22&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; At some point I asked myself whether &lt;em&gt;fail2ban&lt;/em&gt; actually works on the iptables of my server or only operates within the container. Luckily, I was &lt;a href="https://stackoverflow.com/questions/59996070/how-to-understand-if-the-fail2ban-ssh-filter-is-working-with-a-new-port" target="_blank" rel="noopener noreferrer" class="external-link"&gt;not the first with this question (stackoverflow)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and found the proposed solution quite charming:&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;p&gt;At some point I asked myself whether &lt;em&gt;fail2ban&lt;/em&gt; actually works on the iptables of my server or only operates within the container. Luckily, I was &lt;a href="https://stackoverflow.com/questions/59996070/how-to-understand-if-the-fail2ban-ssh-filter-is-working-with-a-new-port" target="_blank" rel="noopener noreferrer" class="external-link"&gt;not the first with this question (stackoverflow)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and found the proposed solution quite charming:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Block any IP in the Docker container (&lt;code&gt;docker exec -it fail2ban sh&lt;/code&gt;): &lt;code&gt;fail2ban-client set sshd banip 111.111.111.111&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Check the iptables to see if this IP appears there: &lt;code&gt;iptables -n -L --line-numbers&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;In my case: enjoy, because it is there: &lt;code&gt;1 REJECT all -- 111.111.111.111&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Unblock the IP again &lt;code&gt;fail2ban-client set sshd unbanip 111.111.111.111&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Using fail2ban to protect more than just ssh&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2024-08-22T00:00:00Z"&gt;&#10; 2024-08-22&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; I have now found a solution for &lt;em&gt;Gitea&lt;/em&gt;, as well as all my other websites:&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Protection against brute-force attacks via &lt;code&gt;ssh&lt;/code&gt; as described above&lt;/li&gt;&#10;&lt;li&gt;Protection against overload using a &lt;a href="https://blog.schallbert.de/en/fail2ban-with-caddy/"&gt;Rate Limiter&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;Protection against attacks on the APIs (&lt;code&gt;404/403&lt;/code&gt; attacks) using &lt;code&gt;caddy-status&lt;/code&gt; configuration for fail2ban, logs in &lt;code&gt;JSON&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /config/fail2ban/filter.d/caddy-status.conf&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# this regex works for caddy with json-style logs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;failregex = &amp;#34;client_ip&amp;#34;:&amp;#34;&amp;lt;HOST&amp;gt;&amp;#34;(.*)&amp;#34;status&amp;#34;:(400|401|403|404|500)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;datepattern = \d+&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;ignoreregex =&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="regular-backups"&gt;Regular backups&lt;/h2&gt;&#10;&lt;p&gt;I want to be able to do disaster recovery. Unforeseen events impacting my server like incompatible updates, machine downtime or security breaches should not keep me from spawning and configuring a new, healthy instance in no time.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;m not willing to create backups manually, copy and compress folders, finally downloading to a backup location via &lt;code&gt;SCP / SFTP&lt;/code&gt;. This should take place automatically, and, ideally, without cost. A quick search reveals two suitable open-source tools: &lt;a href="https://torsion.org/borgmatic/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;borgmatic&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and &lt;a href="https://restic.net/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;restic&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;I randomly chose Borgmatic.&lt;/p&gt;&#10;&lt;h3 id="install-borgmatic-with-docker"&gt;install Borgmatic with Docker&lt;/h3&gt;&#10;&lt;p&gt;As for all other components so far, I want Borgmatic to run in Docker environment. Luckily there are &lt;a href="https://hub.docker.com/r/b3vis/borgmatic/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;ready-made solutions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; that need just a little configuration.&lt;/p&gt;&#10;&lt;p&gt;This is how my &lt;code&gt;docker-compose&lt;/code&gt; file looks like:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /borgmatic/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;version&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#39;3&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;services&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;borgmatic&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;ghcr.io/borgmatic-collective/borgmatic&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container_name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;borgmatic&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;${VOLUME_SOURCE}:/mnt/source:ro &lt;/span&gt; &lt;span style="color:#75715e"&gt;# backup source&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;${VOLUME_TARGET}:/mnt/repository &lt;/span&gt; &lt;span style="color:#75715e"&gt;# backup target&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;${VOLUME_ETC_BORGMATIC}:/etc/borgmatic.d/ &lt;/span&gt; &lt;span style="color:#75715e"&gt;# borgmatic config file(s) + crontab.txt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;${VOLUME_BORG_CONFIG}:/root/.config/borg &lt;/span&gt; &lt;span style="color:#75715e"&gt;# config and keyfiles&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;${VOLUME_SSH}:/root/.ssh &lt;/span&gt; &lt;span style="color:#75715e"&gt;# ssh key for remote repositories&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;${VOLUME_BORG_CACHE}:/root/.cache/borg &lt;/span&gt; &lt;span style="color:#75715e"&gt;# checksums used for deduplication&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# - /var/run/docker.sock:/var/run/docker.sock # add docker sock so borgmatic can start/stop containers to be backupped&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;environment&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;TZ=${TZ}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;BORG_PASSPHRASE=${BORG_PASSPHRASE}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;restart&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;always&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Inspirations were the related &lt;a href="https://github.com/borgmatic-collective/docker-borgmatic/blob/master/README.md" target="_blank" rel="noopener noreferrer" class="external-link"&gt;docs on Github&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. You can see that Borgmatic requires a lot of volumes to work properly, most importantly backup source and target location.&lt;/p&gt;&#10;&lt;h3 id="configure-borgmatic"&gt;configure Borgmatic&lt;/h3&gt;&#10;&lt;p&gt;All concrete data behind &lt;code&gt;${}&lt;/code&gt; are summarized in an &lt;code&gt;.env&lt;/code&gt; environment variable file. This helps me avoid publishing passphrases by accident. Of course I have noted down the passphrase on paper as &amp;ldquo;backup&amp;rdquo; of the backup.&lt;/p&gt;&#10;&lt;p&gt;I then modify &lt;code&gt;config.yml&lt;/code&gt; in &lt;code&gt;borgmatic.d/&lt;/code&gt; slightly, adding backup source, target, count, and cycle. That&amp;rsquo;s all, I&amp;rsquo;m ready for a first test.&lt;/p&gt;&#10;&lt;h3 id="testing-borgmatic-backup"&gt;testing Borgmatic backup&lt;/h3&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker exec borgmatic bash -c &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;cd &amp;amp;&amp;amp; borgmatic --stats -v 1 --files 2&amp;gt;&amp;amp;1&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This command has &lt;code&gt;borgmatic&lt;/code&gt; create a backup for me through the Docker container.&lt;/p&gt;&#10;&lt;p&gt;I got an error message right away, saying &lt;code&gt;repository does not exist&lt;/code&gt;. A look into the docs reveals that I have to manually create the backup target repository.&lt;/p&gt;&#10;&lt;p&gt;The following command does this, creating an empty, encrypted repo:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker exec borgmatic bash -c &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;borgmatic init --encryption repokey-blake2&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now I retry manual backup creation which throws no error&#10;.&lt;/p&gt;&#10;&lt;h3 id="ein-backup-only-is-a-backup"&gt;Ein Backup only is a Backup&amp;hellip;&lt;/h3&gt;&#10;&lt;p&gt;&amp;hellip;when it is successfully applied, a friend of mine said.&lt;/p&gt;&#10;&lt;p&gt;Right he is. Still, I don&amp;rsquo;t dare replacing my working server configuration with a backup. As a compromise, I go halfway by creating a &lt;code&gt;docker-compose.restore.yml&lt;/code&gt; following the &lt;a href="https://github.com/borgmatic-collective/docker-borgmatic/blob/master/docker-compose.restore.yml" target="_blank" rel="noopener noreferrer" class="external-link"&gt;online guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. For additional help, I read &lt;a href="https://www.modem7.com/books/docker-backup/page/backup-docker-using-borgmatic" target="_blank" rel="noopener noreferrer" class="external-link"&gt;modem7&amp;rsquo;s related blog post&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;For my test purposes, I run &lt;code&gt;docker-compose.restore.yml&lt;/code&gt; in the container&amp;rsquo;s shell and enter the following commands:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;mkdir backuprestoremount&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;borg mount /mnt/repository /backuprestoremount&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;mkdir backuprestore&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;borgmatic extract --archive latest --destination /backuprestore&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here, I create the folder &lt;code&gt;/backuprestoremount&lt;/code&gt; and have it point to my borg backup. Then, I extract the backup into &lt;code&gt;/backuprestore&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Let&amp;rsquo;s do a sanity check:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# cd /mnt/backuprestore&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /restore/mnt/source ls&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;borgmatic caddy2 containerd fail2ban gitea hostedtoolcache watchtower&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Yeah! The backup contains all applications along with their files. I stop at this point as I&amp;rsquo;m too lazy and cowardly to overwrite my working setup as mentioned above.&lt;/p&gt;&#10;&lt;h3 id="what-yet-doesnt-work-borgmatic--docker-compose-down"&gt;What (yet) doesn&amp;rsquo;t work: Borgmatic &amp;amp; docker-compose down&lt;/h3&gt;&#10;&lt;p&gt;Borgmatic offers a simple method to run pre- and post-backup actions. As I want to maintain consistency within my containers, I want them to be shutdown before and restarted after the backup run. So I wrote a script, hooked it into the config and&amp;hellip; nothing.&lt;/p&gt;&#10;&lt;p&gt;Easy to follow, this can only work directly if Borgmatic ran on bare metal. Docker&amp;rsquo;s encapsulation doesn&amp;rsquo;t let borgmatic run shell scripts outside its container. I thought I could circumvent this problem by including &lt;code&gt;var/run/docker.sock&lt;/code&gt; in the docker-compose file but without success.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;m sure this can be solved somehow. For the time being, I have more important problems to solve and thus I&amp;rsquo;ll live with the assumption &amp;ldquo;there are no data inconsistencies&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;The SSH-Daemon of my server puts its authentication logs to &lt;code&gt;/var/log/auth.log&lt;/code&gt;, but could also be named &lt;code&gt;access.log&lt;/code&gt; or similar in other applications. Services like caddy or gitea also create auth log files.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:2"&gt;&#10;&lt;p&gt;However, what I yet do not understand is how &lt;code&gt;fail2ban&lt;/code&gt; is able to access the iptables from within the container. I thought a positive side effect of containerization is encapsulation. Or is that only complete with &amp;ldquo;rootless&amp;rdquo;-Containers?&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:3"&gt;&#10;&lt;p&gt;Background: I&amp;rsquo;m currently the only one contributing to my &lt;code&gt;gitea&lt;/code&gt; instance. My website is static. The webserver &lt;code&gt;caddy&lt;/code&gt; only handles changing files once pushed to Gitea. I have scheduled application updates per &lt;code&gt;watchtower&lt;/code&gt; and &lt;code&gt;unattended-upgrades&lt;/code&gt; &lt;a href="https://blog.schallbert.de/en/server-auto-upgrade/"&gt;(see my follow-up post)&lt;/a&gt; at night so they won&amp;rsquo;t interfere with &lt;code&gt;borgmatic&lt;/code&gt; runs. Just the logs for &lt;code&gt;fail2ban&lt;/code&gt; are still written independently. I accept the risk of data loss here because packet filtering is subject to change anyways.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>Jekyll build: broken relative links</title><link>https://blog.schallbert.de/en/self-hosted-jekyll-page-broken-links/</link><pubDate>Fri, 29 Dec 2023</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/self-hosted-jekyll-page-broken-links/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-12-29_serves_broken_links-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Webpage, displaying Schallbert&amp;#39;&amp;#39;s Blog with broken links"&#10; title="Jekyll build: broken relative links" /&gt;&#10;&lt;h2 id="problem"&gt;Problem&lt;/h2&gt;&#10;&lt;p&gt;Now, after &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/"&gt;eternal trial and error&lt;/a&gt;, I am finally able to provide my Caddy server with the build files via Docker-Volume. Unfortunately, the page looks like this:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-12-29_serves_broken_links.jpg" alt="Image: Broken webpage, displaying Schallbert&amp;#39;s Blog without pictures and other media, and any links lead to 404-nowhere"&gt;&lt;/figure&gt;&#10;&lt;h2 id="analysis"&gt;Analysis&lt;/h2&gt;&#10;&lt;p&gt;At first I suspected the web server.&#10;But when I call up the website in my browser&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt; as a test, I see the same behavior. Strange.&lt;/p&gt;&#10;&lt;p&gt;Obviously all stylesheets are missing. But if you look closely, images and other media have not been loaded either. Only their descriptions.&#10;Then I click wildly on a few links to articles and subpages. I notice that the paths look different than I would have expected:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# --- Expectation ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Image&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;/assets/images/test.jpg&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Post&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;/this-is-a-post&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Instead I see:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# --- Observation ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Image&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;/pages/&amp;lt;username&amp;gt;/&amp;lt;repositoryname&amp;gt;/assets/images/test.jpg&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Post&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;/pages/&amp;lt;username&amp;gt;/&amp;lt;repositoryname&amp;gt;/this-is-a-post&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is also shown by the browser&amp;rsquo;s developer options activated with &lt;code&gt;F12&lt;/code&gt; (see image above): all sources integrated via a relative link cannot be loaded. It is generally a clear recommendation to have the developer options activated in the event of problems with the display of websites.&lt;/p&gt;&#10;&lt;p&gt;It is somehow obvious that my Jekyll configuration file &lt;code&gt;_config.yml&lt;/code&gt; or my &lt;a href="https://blog.schallbert.de/en/jekyll-polyglot-language-support/"&gt;translation header&lt;/a&gt; &lt;code&gt;i10.yml&lt;/code&gt; contain errors. Fortunately, there are already excellent &lt;a href="https://mademistakes.com/mastering-jekyll/site-url-baseurl/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;help pages&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;However, a test quickly shows that I cannot leave the attribute &lt;code&gt;repository&lt;/code&gt; empty, where the build &lt;code&gt;&amp;lt;username&amp;gt;/&amp;lt;repositoryname&amp;gt;&lt;/code&gt; emanates from:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# bundle exec jekyll serve&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Generating... &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Jekyll Feed: Generating feed &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; posts&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Liquid Exception: No repo name found. &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Specify using PAGES_REPO_NWO environment variables, &lt;span style="color:#e6db74"&gt;&amp;#39;repository&amp;#39;&lt;/span&gt; in your configuration, &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; or set up an &lt;span style="color:#e6db74"&gt;&amp;#39;origin&amp;#39;&lt;/span&gt; git remote pointing to your github.com repository. in /_layouts/default.html&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ERROR: YOUR SITE COULD NOT BE BUILT&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And, heck, where does the &lt;code&gt;/pages&lt;/code&gt; suddenly come from?&lt;/p&gt;&#10;&lt;h2 id="solution"&gt;Solution&lt;/h2&gt;&#10;&lt;p&gt;As &lt;code&gt;/pages&lt;/code&gt; is added to paths I suspect &lt;a href="https://jekyllrb.com/docs/configuration/options/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Jekyll build options&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and &lt;a href="https://github.com/github/pages-gem" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github-Pages Gem&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to be the culprits.&lt;/p&gt;&#10;&lt;p&gt;Of course I&amp;rsquo;m not alone with this problem, so I found the &lt;a href="https://stackoverflow.com/questions/51869314/jekyll-serve-generate-wrong-path-in-localhost" target="_blank" rel="noopener noreferrer" class="external-link"&gt;solution documented&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;I just change &lt;code&gt;JEKYLL_ENV&lt;/code&gt; (build environment) in the action script from &lt;code&gt;production&lt;/code&gt; to &lt;code&gt;development&lt;/code&gt;, like it is normally done for building on a local machine.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# workflows/jekyll-build-action.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;env&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;JEKYLL_ENV&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;development&lt;/span&gt; &lt;span style="color:#75715e"&gt;# I had &amp;#34;production&amp;#34; here before&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And what can I say: as soon as you do it right, it works!&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-12-29_css_assets_show_correctly.jpg" alt="Image: blog.schallbert.de shows correct CSS and media are loaded along working links"&gt;&lt;/figure&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;Displaying the website offline works as follows: Drag the built files (the &lt;code&gt;_site&lt;/code&gt; folder) to your own computer and open the top-level &lt;code&gt;index.html&lt;/code&gt; with the browser.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>🗸 Gitea Actions Part2 - Jekyll-Dockerimage</title><link>https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/</link><pubDate>Sat, 09 Dec 2023</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-12-09_gitea_infrastructure-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: giteas infrastructure in a nutshell"&#10; title="🗸 Gitea Actions Part2 - Jekyll-Dockerimage" /&gt;&#10;&lt;aside class="update-box update-box--warn" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ⚠️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Gitea Retires `act_runner`&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-09-15T00:00:00Z"&gt;&#10; 2026-09-15&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; This article refers to an Actions implementation by Gitea, the &lt;code&gt;act_runner&lt;/code&gt;. It is derived from &lt;a href="https://github.com/nektos/act" target="_blank" rel="noopener noreferrer" class="external-link"&gt;nectos/act&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Gitea now uses &lt;a href="https://blog.gitea.com/release-of-runner-1.0.0/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;its own runner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The old runner should be replaced. More info: Read my post to &lt;a href="https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/"&gt;deploy hugo with Gitea Actions, docker, and caddy&lt;/a&gt;&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h2 id="what-happened-so-far"&gt;What happened so far&lt;/h2&gt;&#10;&lt;p&gt;I wasn&amp;rsquo;t able to set up the CI/CD pipeline in &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-native-jekyll/"&gt;Part1&lt;/a&gt; of this sequel. I tried with a minor modified copy of the Github-Action to build Jekyll for Github Pages. Now I wanted to try running Jekyll as a Dockerimage right from the start. There wasn&amp;rsquo;t too much choice on the Docker hub, still I found something which seemed well-documented and actively maintained at year-end 2023.&lt;/p&gt;&#10;&lt;h2 id="set-up-job"&gt;&amp;ldquo;Set up job&amp;rdquo;&lt;/h2&gt;&#10;&lt;p&gt;So I add the Docker-jekyllimag to my workflow:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# workflows/jekyll-build-action.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;jobs&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Build job&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;build&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;runs-on&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;ubuntu-latest&lt;/span&gt; &lt;span style="color:#75715e"&gt;# this is the &amp;#34;label&amp;#34; the runner will use and map to docker target OS&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;jvconseil/jekyll-docker&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here I&amp;rsquo;m telling &lt;code&gt;gitea act_runner&lt;/code&gt; to run with &lt;code&gt;ubuntu-latest&lt;/code&gt; label which in my case points to a minimalistic &lt;code&gt;node16:bullseye&lt;/code&gt; machine (Debian11). I&amp;rsquo;m then telling docker to load the &lt;code&gt;jekyll-docker&lt;/code&gt; image, where the dependencies I require for my build are already loaded.&lt;/p&gt;&#10;&lt;h2 id="bundle-install"&gt;bundle install&lt;/h2&gt;&#10;&lt;p&gt;Well, initial build fails due to ruby dependencies that are specified in my project&amp;rsquo;s &lt;code&gt;Gemfile&lt;/code&gt; which are not present yet. The error message looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;bundler: failed to load command: jekyll &lt;span style="color:#f92672"&gt;(&lt;/span&gt;/usr/gem/bin/jekyll&lt;span style="color:#f92672"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;/usr/local/lib/ruby/gems/3.2.0/gems/bundler-2.4.22/lib/bundler/resolver.rb:332:in &lt;span style="color:#e6db74"&gt;`&lt;/span&gt;raise_not_found!&lt;span style="color:#e6db74"&gt;&amp;#39;: Could not find gem &amp;#39;&lt;/span&gt;github-pages&lt;span style="color:#e6db74"&gt;&amp;#39; in locally installed gems. (Bundler::GemNotFound)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt;&#9;from /usr/local/lib/ruby/gems/3.2.0/gems/bundler-2.4.22/lib/bundler/resolver.rb:392:in `block in prepare_dependencies&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;a href="https://bundler.io" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Bundler&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is Ruby&amp;rsquo;s packet manager. Similar to &lt;code&gt;npm&lt;/code&gt; for Javascript or &lt;code&gt;pip&lt;/code&gt; für Python, &lt;code&gt;bundler&lt;/code&gt; is able to load and bind dependencies and libraries for applications running in Ruby. The &lt;code&gt;Gemfile&lt;/code&gt; specifies the packages for &lt;code&gt;bundler&lt;/code&gt; to install.&lt;/p&gt;&#10;&lt;p&gt;So let&amp;rsquo;s add &lt;code&gt;bundle install&lt;/code&gt; to the script like so:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# workflows/jekyll-build-action.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;steps&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;CHECKOUT ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/checkout@v3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;INSTALL GEMS ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;bundle install&lt;/span&gt; &lt;span style="color:#75715e"&gt;# will fail with permissions rights to write to Gemfile.lock but anyways installs required dependencies.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;BUILD WITH JEKYLL ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Outputs to the &amp;#39;./_site&amp;#39; directory by default&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;bundle exec jekyll build --destination /opt/blog_staging&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;env&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;JEKYLL_ENV&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;production&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="act_runner-no-write-permissions-to-gemfilelock"&gt;act_runner: no write permissions to &lt;code&gt;Gemfile.lock&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;This fails again. &lt;code&gt;act_runner&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; | &lt;span style="color:#e6db74"&gt;`&lt;/span&gt;/workspace/schallbert/blog/Gemfile.lock&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;. It is likely that you need to grant&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; | write permissions &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; that path.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; ❌ Failure - Main ---INSTALL GEMS ---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; exitcode &lt;span style="color:#e6db74"&gt;&amp;#39;23&amp;#39;&lt;/span&gt;: failure&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After a lenghty search I saw that &lt;code&gt;Gemfile.lock&lt;/code&gt; wasn&amp;rsquo;t added to version control on my local machine because it is mentioned in &lt;code&gt;.gitignore&lt;/code&gt;. So bundler in &lt;code&gt;act_runner&lt;/code&gt; now tries to create it out of the given &lt;code&gt;Gemfile&lt;/code&gt; without success as it only has read access to the checked-out dataset.&lt;/p&gt;&#10;&lt;p&gt;To solve this, I add &lt;code&gt;Gemfile.lock&lt;/code&gt; to version control, removing it from &lt;code&gt;.gitignore&lt;/code&gt;. As an additional benefit I make sure to have identical build environments locally and remote.&lt;/p&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Fixing bundler issues&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2025-11-04T00:00:00Z"&gt;&#10; 2025-11-04&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; After updating my local build environment via &lt;code&gt;bundle update&lt;/code&gt;, I&amp;rsquo;m experiencing the aforementioned problem again, even though I&amp;rsquo;m now versioning &lt;code&gt;Gemfile.lock&lt;/code&gt;. I discuss the solution in an &lt;a href="https://blog.schallbert.de/en/bundler-ci-gemfile-issue/"&gt;article on the topic of Bundler&lt;/a&gt;.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h3 id="dependency-sass-embedded"&gt;Dependency: &lt;code&gt;sass-embedded&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;Less than 20sec into the build I get the following error:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;Resolving dependencies...&#10;Could not find gem &amp;#39;sass-embedded (= 1.69.5)&amp;#39; with platform &amp;#39;x86_64-linux&amp;#39; in&#10;rubygems repository https://rubygems.org/ or installed locally.&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After some more searching, I found a &lt;a href="https://github.com/helaili/jekyll-action/issues/150#issuecomment-1374388728" target="_blank" rel="noopener noreferrer" class="external-link"&gt;resolution&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;: Specify a certain version of &lt;code&gt;jekyll-sass-converter&lt;/code&gt; in the action or, alternatively, add the gem &amp;ldquo;github-pages&amp;rdquo; to the Gemfile. The latter manages to load a working version of CascadingStyleSheets (CSS) Preprocessor &lt;a href="https://sass-lang.com/guide/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Sass&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Finally, &lt;code&gt;bundle install&lt;/code&gt; finishes successfully:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;✅ Bundle complete! &lt;span style="color:#ae81ff"&gt;5&lt;/span&gt; Gemfile dependencies, &lt;span style="color:#ae81ff"&gt;43&lt;/span&gt; gems now installed.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="jekyll-build"&gt;jekyll build&lt;/h2&gt;&#10;&lt;p&gt;As I want &lt;code&gt;act_runner&lt;/code&gt; to save the build output on my host machine, I add a &lt;a href="https://docs.docker.com/storage/volumes/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Volume&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to Gitea&amp;rsquo;s &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./runner/blog_staging:/opt/blog_staging&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./runner/data:/data&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Let&amp;rsquo;s retry the build:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt; Destination: /opt/blog_staging&#10; Generating... &#10; Jekyll Feed: Generating feed for posts&#10;jekyll 3.9.3 | Error: Permission denied @ dir_s_mkdir - /opt/blog_staging&#10;/usr/local/lib/ruby/3.2.0/fileutils.rb:406:in `mkdir&amp;#39;: Permission denied @ dir_s_mkdir - /opt/blog_staging (Errno::EACCES)&#10;&#9;from /usr/local/lib/ruby/3.2.0/fileutils.rb:406:in `fu_mkdir&amp;#39;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Also this problem can easily be solved: Either you build to &lt;code&gt;/tmp&lt;/code&gt;, where the &lt;code&gt;jekyll&lt;/code&gt; user has access. Or you hand over the output directory like so: &lt;code&gt;chown -R jekyll /your/build/output&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;With this change, I get successful action runs.&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-12-09_buildsuccessful.jpg" alt="Image: Gitea snapshot showing a successful build"&gt;&lt;/figure&gt;&lt;/p&gt;&#10;&lt;h2 id="transfer-artifacts"&gt;Transfer artifacts&lt;/h2&gt;&#10;&lt;p&gt;Where do I find the build artifacts now? I don&amp;rsquo;t see any &lt;code&gt;_site&lt;/code&gt; folder: Neither on the host machine&amp;rsquo;s volume, nor in the Gitea or Runner containers.&lt;/p&gt;&#10;&lt;h3 id="docker-volumes-for-act_runner-cannot-share-build-artifact"&gt;Docker volumes for &lt;code&gt;act_runner&lt;/code&gt; cannot share build artifact&lt;/h3&gt;&#10;&lt;p&gt;This seems logical as I learn the mechanics of &lt;code&gt;act-runner&lt;/code&gt;: It will spawn an own action Dockerimage with own volumes that are associated by Task-ID.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;DRIVER VOLUME NAME&#10;--&amp;gt; local GITEA-ACTIONS-TASK-84_WORKFLOW-Deploy-Jekyll-site_JOB-build &amp;lt;--&#10;--&amp;gt; local GITEA-ACTIONS-TASK-84_WORKFLOW-Deploy-Jekyll-site_JOB-build-env &amp;lt;--&#10;local act-toolcache&#10;local blog_staging&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If I do not extract the build artifacts from the action run, they will be thrown away together with the action run when it has completed and everything is gone. The runner&amp;rsquo;s volume set up &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/#jekyll-build"&gt;above&lt;/a&gt; doesn&amp;rsquo;t touch the action as it again executes in an isolated image.&lt;/p&gt;&#10;&lt;p&gt;To really understand this has cost me a lot of time and many failing action runs. Don&amp;rsquo;t repeat my mistakes and read the according &lt;a href="https://docs.gitea.com/usage/actions/act-runner?_highlight=runner#register-the-runner-with-docker" target="_blank" rel="noopener noreferrer" class="external-link"&gt;section in Gitea&amp;rsquo;s documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; thoroughly.&lt;/p&gt;&#10;&lt;p&gt;Well, this turns out more complicated than I thought. As a single maintainer of my website, this is like taking a sledgehammer to crack a nut. Anyways, I&amp;rsquo;ll follow through.&lt;/p&gt;&#10;&lt;h3 id="but-i-can-use-upload-artifact-right"&gt;But I can use &lt;code&gt;upload-artifact&lt;/code&gt;, right?&lt;/h3&gt;&#10;&lt;p&gt;Right? Well, at least I thought so. I modified my Action script accordingly and used the upload function from the Github Actions marketplace:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# workflows/jekyll-build-action.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Automatically upload the build folder to Giteas blog repo folder&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;UPLOAD ARTIFACT ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/upload-artifact@v3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;with&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;path&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;/workspace/schallbert/blog/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Blog_Staging&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;retention-days&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Great, now I have a downloadable Zip file on Gitea&amp;rsquo;s web surface:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-12-09_uploadsuccessful.jpg" alt="Image: Gitea snapshot showing a successful artifact upload"&gt;&lt;/figure&gt;&#10;&lt;p&gt;But how do I now get that downloaded to the server&amp;rsquo;s disk? The action is fully encapsuled and cannot even access its underlying Docker Daemon (&lt;a href="https://blog.schallbert.de/en/gitea-action-runner-native-jekyll/#run-the-runner-on-the-hosts-operating-system"&gt;for good reasons, I guess&lt;/a&gt;).&lt;/p&gt;&#10;&lt;p&gt;So I search for the assets in the Gitea-Container that persists the artifacts at&lt;code&gt;/data/gitea/actions_artifacts/BUILD_ID&lt;/code&gt;. Unfortunately, they are a heap (hundreds) of &lt;code&gt;.chunk.gz&lt;/code&gt; files with cryptic numbers as name where I don&amp;rsquo;t know how to merge into one single archive.&lt;/p&gt;&#10;&lt;p&gt;And there&amp;rsquo;s another thing I don&amp;rsquo;t like about &lt;code&gt;upload-artifact&lt;/code&gt;: My website is becoming bigger and bigger due to an increasing amount of media that I use. That&amp;rsquo;s why the uploader requires &lt;em&gt;nearly two minutes&lt;/em&gt; for compressing and packing - with a linear ascending outlook.&lt;/p&gt;&#10;&lt;p&gt;Dead end.&lt;/p&gt;&#10;&lt;h3 id="also-docker-cp-wont-work-here"&gt;Also Docker &lt;code&gt;cp&lt;/code&gt; won&amp;rsquo;t work here&lt;/h3&gt;&#10;&lt;p&gt;Then I try using Docker&amp;rsquo;s copy operation to get the artifacts to my host machine:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# workflows/jekyll-build-action.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;COPY ARTIFACT ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;docker cp gitea-runner-1:/workspace/schallbert/blog /tmp/blog_staging&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This fails like so:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | docker cp gitea-runner-1:/workspace/schallbert/blog /tmp/blog_staging&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; | /var/run/act/workflow/3.sh: line 2: docker: not found&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; ❌ Failure - Main --- COPY ARTIFACT ---&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This was expected as the action container knows no Docker. I cannot move files from &amp;ldquo;inside&amp;rdquo; the container to another one.&#10;Or can I? By forwarding the host machine&amp;rsquo;s Daemon &lt;code&gt;docker.sock&lt;/code&gt;, according to Gitea documentation, I might be tempted&amp;hellip; No, I will not continue here - see above, I&amp;rsquo;d puncture encapsulation if I did.&lt;/p&gt;&#10;&lt;h3 id="transfer-artifacts-via-sftp"&gt;Transfer artifacts via SFTP&lt;/h3&gt;&#10;&lt;p&gt;I&amp;rsquo;m running low on options. I think I could copy the artifacts via &lt;a href="https://de.wikipedia.org/wiki/SSH_File_Transfer_Protocol" target="_blank" rel="noopener noreferrer" class="external-link"&gt;SFTP&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; from within the &lt;code&gt;act_runner&lt;/code&gt;&amp;rsquo;s action image into the Webserver-Container&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-12-09_sftp_infrastructure.jpg" alt="Image: Machine architecture if I used SFTP for artifact share between Gitea and webserver"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Luckily, there already is an Action for this: &lt;a href="https://github.com/marketplace/actions/scp-files" target="_blank" rel="noopener noreferrer" class="external-link"&gt;scp-files&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Now I have to modify the webserver&amp;rsquo;s container in a way to be accessible via SCP, inhale an SSH-key, and save incoming artifacts.&lt;/p&gt;&#10;&lt;p&gt;On the other hand, &lt;a href="https://stackoverflow.com/questions/65381311/run-sshd-in-docker-container" target="_blank" rel="noopener noreferrer" class="external-link"&gt;people in multiple forums&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; say that this is not a clean solution. &amp;ldquo;Ac container should only accommodate one application. But there&amp;rsquo;s a solution again: &lt;a href="https://hub.docker.com/r/panubo/sshd/#!" target="_blank" rel="noopener noreferrer" class="external-link"&gt;A docker container with only the SSH-Daemon installed&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. If I have a shared volume between that one and my server, I might be successful.&lt;/p&gt;&#10;&lt;p&gt;Looks like more work still. Another container, piping files out and back into my system - that just doesn&amp;rsquo;t seem right. So I continue my research.&lt;/p&gt;&#10;&lt;h3 id="action-volumes"&gt;Handover a volume to the Action-Container&lt;/h3&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-12-09_gitea_infrastructure.jpg" alt="Image: Docker-Gitea infrastructure overview"&gt;&lt;/figure&gt;&#10;&lt;p&gt;All these failures and dead ends have been with me for over a month now. I still really want to be able to build, test and publish automatically. I&amp;rsquo;ve learned a lot along the way and now I&amp;rsquo;m hoping that this approach will finally get me to the solution.&lt;/p&gt;&#10;&lt;p&gt;During &lt;a href="https://gitea.com/gitea/act_runner/issues/329" target="_blank" rel="noopener noreferrer" class="external-link"&gt;my search&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; I came across &lt;a href="https://gitea.com/gitea/act_runner/src/branch/main/internal/pkg/config/config.example.yaml" target="_blank" rel="noopener noreferrer" class="external-link"&gt;the option to change the configuration of the runner&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. That way, I might end up including a volume that I can share between the action and my server via the daemon on the host.&lt;/p&gt;&#10;&lt;p&gt;If that works, I wouldn&amp;rsquo;t have an additional security risk like a publicly available &lt;code&gt;file transfer container&lt;/code&gt; or the long waiting times caused by &lt;code&gt;upload-artifact&lt;/code&gt;. So let&amp;rsquo;s get to work! There are &lt;a href="https://gitea.com/gitea/act_runner/issues/407" target="_blank" rel="noopener noreferrer" class="external-link"&gt;more reasons&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; why volumes can make sense in the respective action - so there must already be people out there who have managed this.&lt;/p&gt;&#10;&lt;p&gt;So I add a volume to the Action script:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# workflows/jekyll-build-action.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;container&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;jvconseil/jekyll-docker:latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/tmp/blog_staging:/blog_staging&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/opt/cache:/opt/hostedtoolcache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But the artifacts still don&amp;rsquo;t show up on my host&amp;rsquo;s filesystem. I strip down the action as much as I can to just check if the volume creates a folder in the action container:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# workflows/jekyll-build-action.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;steps&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: --- &lt;span style="color:#ae81ff"&gt;CHECK_VOLUME ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: |&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; ls -al /blog_staging&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But still:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;gitea-runner-1 | [Deploy Jekyll site/build] [DEBUG] Working directory &amp;#39;/workspace/schallbert/blog&amp;#39;&#10;gitea-runner-1 | [Deploy Jekyll site/build] | ls: /blog_staging: No such file or directory&#10;gitea-runner-1 | [Deploy Jekyll site/build] ❌ Failure - Main --- CHECK_VOLUME ---&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Some more, fruitless tries (maybe I had misspelled anything?) and extensive research in multiple forums I found out that there is a &lt;a href="https://gitea.com/gitea/act_runner/issues/329" target="_blank" rel="noopener noreferrer" class="external-link"&gt;valid_volumes&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; attribute in &lt;code&gt;act&lt;/code&gt;. If I don&amp;rsquo;t have the volume added here, it won&amp;rsquo;t bind to the container.&lt;/p&gt;&#10;&lt;p&gt;OK, let&amp;rsquo;s add it to the action script:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# jekyll-build-action.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;container&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;jvconseil/jekyll-docker:latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;valid_volumes&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#e6db74"&gt;&amp;#39;**&amp;#39;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# This does not work. Also specialized lists indicating the volumes directly won&amp;#39;t work&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;: &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/tmp/blog_staging:/blog_staging&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/opt/cache:/opt/hostedtoolcache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Still nothing. At least, now, I get a warning in the logs:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;gitea-runner-1 | [Deploy Jekyll site/build] [/tmp/blog_staging] is not a valid volume, will be ignored&#10;gitea-runner-1 | [Deploy Jekyll site/build] [/opt/cache] is not a valid volume, will be ignored&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Something is wrong with the transfer of this option. There are &lt;a href="https://gitea.com/gitea/act_runner/issues/407" target="_blank" rel="noopener noreferrer" class="external-link"&gt;reports&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; in which the registration of the volumes works. So I take the &lt;a href="https://gitea.com/gitea/act_runner/src/branch/main#configuration" target="_blank" rel="noopener noreferrer" class="external-link"&gt;instructions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and create a configuration file where I enter the following:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# runner/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# valid_volumes:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# - data&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# - /src/*.json&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# If you want to allow any volume, please use the following configuration:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# valid_volumes:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# - &amp;#39;**&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;valid_volumes&lt;/span&gt;: [&lt;span style="color:#e6db74"&gt;&amp;#34;/tmp/blog_staging&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;/opt/hostedtoolcache&amp;#34;&lt;/span&gt;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;em&gt;Important:&lt;/em&gt; The notation must be a string, comma-separated, and always has to specify the &lt;code&gt;Source&lt;/code&gt; of the volume. In other words, the part that comes before the &lt;code&gt;:&lt;/code&gt;.&#10;However, the error message &amp;ldquo;not a valid volume&amp;rdquo; still appears when building.&lt;/p&gt;&#10;&lt;p&gt;But then I realize that the config must be made available to the &lt;code&gt;act_runner&lt;/code&gt; itself as a volume - otherwise the runner running in the container cannot access it at all!&lt;/p&gt;&#10;&lt;p&gt;So my &lt;code&gt;docker-compose.yml&lt;/code&gt; for Gitea, section &amp;ldquo;runner&amp;rdquo; now looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;runner&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/act_runner:latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;environment&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;CONFIG_FILE=/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_INSTANCE_URL=https://git.schallbert.de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_RUNNER_NAME=ichlaufe&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_RUNNER_REGISTRATION_TOKEN= &amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./runner/config.yml:/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./runner/data:/data&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/opt/hostedtoolcache:/opt/hostedtoolcache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And, finally:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | ls -al /blog_staging&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; | total &lt;span style="color:#ae81ff"&gt;8&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; | drwxr-xr-x &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt; root root &lt;span style="color:#ae81ff"&gt;4096&lt;/span&gt; Dec &lt;span style="color:#ae81ff"&gt;27&lt;/span&gt; 07:32 .&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; | drwxr-xr-x &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt; root root &lt;span style="color:#ae81ff"&gt;4096&lt;/span&gt; Dec &lt;span style="color:#ae81ff"&gt;27&lt;/span&gt; 07:46 ..&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | &lt;span style="color:#f92672"&gt;[&lt;/span&gt;Deploy Jekyll site/build&lt;span style="color:#f92672"&gt;]&lt;/span&gt; ✅ Success - Main --- CHECK_VOLUME ---&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;What an act. I&amp;rsquo;m so glad that everything is running smoothly now and that I can actually find the build files on my local host system! 🥳&lt;/p&gt;&#10;&lt;h2 id="reverse-proxy-connection-refused"&gt;One last tip&lt;/h2&gt;&#10;&lt;p&gt;If you work with a reverse proxy like I do and get strange &lt;code&gt;connection refused&lt;/code&gt; error messages when starting up the runner like:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker compose up&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;+&lt;span style="color:#f92672"&gt;]&lt;/span&gt; Running 2/0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ✔ Container gitea Created 0.0s &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ✔ Container gitea-runner-1 Created 0.0s &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Attaching to gitea, gitea-runner-1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | level&lt;span style="color:#f92672"&gt;=&lt;/span&gt;info msg&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Starting runner daemon&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | level&lt;span style="color:#f92672"&gt;=&lt;/span&gt;error msg&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;fail to invoke Declare&amp;#34;&lt;/span&gt; error&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;unavailable: dial tcp &amp;lt;address&amp;gt;: connect: connection refused&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 | Error: unavailable: dial tcp &amp;lt;address&amp;gt; connect: connection refused&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea | Server listening on :: port 22.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;gitea-runner-1 exited with code &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then your reverse proxy is either not correctly configured or - like in my case - shut down.&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;Using SFTP (Secure File Transfer Protocol) which is file sharing per ssh (secure shell), I&amp;rsquo;d increase attack surface of my system just a bit. This is because the SSH client would be reachable for anyone and not just for my action.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>🗙 Gitea Actions - Jekyll Workflow</title><link>https://blog.schallbert.de/en/gitea-action-runner-native-jekyll/</link><pubDate>Thu, 30 Nov 2023</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/gitea-action-runner-native-jekyll/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-11-30_ghaction-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: github actions workflow to build Jekyll page"&#10; title="🗙 Gitea Actions - Jekyll Workflow" /&gt;&#10;&lt;p&gt;After setting up my own little server in &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;this project&lt;/a&gt; and adding all necessary applications to serve my blog, I now want to automatically build, integrate, and deploy.&lt;/p&gt;&#10;&lt;h2 id="desired-outcome"&gt;Desired outcome&lt;/h2&gt;&#10;&lt;p&gt;This is how I want it to be: When my Gitea instance detects a &lt;code&gt;git push&lt;/code&gt; to the remote origin of my blog&amp;rsquo;s repository, I want an action runner to start a Docker container. That container shall load Ruby, Bundler, Jekyll, and all other required dependencies. Then it shall checkout the blog&amp;rsquo;s source files and then run the &lt;code&gt;bundle exec jekyll build&lt;/code&gt; command. When its output is ready in the &lt;code&gt;_site&lt;/code&gt; folder, I want the runner to self-terminate and to free all occupied resources.&lt;/p&gt;&#10;&lt;p&gt;At this point I&amp;rsquo;d like to manually check my blog&amp;rsquo;s integrity, maybe also add some automated checks like detecting broken links or similar.&lt;/p&gt;&#10;&lt;p&gt;Finally, I want to be able to have the site deployed and thus made available to the public with another simple command.&lt;/p&gt;&#10;&lt;h2 id="starting-point-github-actions"&gt;Starting point: Github Actions&lt;/h2&gt;&#10;&lt;p&gt;As usual, I first search other people&amp;rsquo;s solutions for the same problem. I found the &lt;a href="https://github.com/actions/starter-workflows/blob/main/pages/jekyll.yml" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github Actions starter-workflows&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; Repository, created by Github themselves, and copied most of it into my action as a first shot:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# jekyll-build-pages.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Sample workflow for building and deploying a Jekyll site&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Deploy Jekyll site&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;run-name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;${{ gitea.actor }} builds Jekyll site&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;on&lt;/span&gt;: [&lt;span style="color:#ae81ff"&gt;push]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;jobs&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Build job&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;build&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;runs-on&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;ubuntu-latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;steps&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Checkout&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/checkout@v3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Setup Ruby&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf&lt;/span&gt; &lt;span style="color:#75715e"&gt;# v1.146.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;with&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;ruby-version&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#39;3.1&amp;#39;&lt;/span&gt; &lt;span style="color:#75715e"&gt;# Not needed with a .ruby-version file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;bundler-cache&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt; &lt;span style="color:#75715e"&gt;# runs &amp;#39;bundle install&amp;#39; and caches installed gems automatically&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;cache-version&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt; &lt;span style="color:#75715e"&gt;# Increment this number if you need to re-download cached gems&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#[...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After uploading this workflow to my blog at &lt;code&gt;workflows/jekyll.yml&lt;/code&gt;, the runner indeed started its work:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-11-30_rubyfails.jpg" alt="Image: Gitea action runner returns error on ruby install"&gt;&lt;/figure&gt;&#10;&lt;h2 id="problems-installing-ruby"&gt;Problems installing Ruby&lt;/h2&gt;&#10;&lt;p&gt;That was a quick win. On the other hand, the runner wasn&amp;rsquo;t even able to successfully install Ruby. Here&amp;rsquo;s the corresponding log:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;::error::The current runner (debian-11-x64) was detected as self-hosted because the platform does not match a GitHub-hosted runner image &#10;(or that image is deprecated and no longer supported).&#10;In such a case, you should install Ruby in the $RUNNER_TOOL_CACHE yourself, for example using https://github.com/rbenv/ruby-build&#10;You can take inspiration from this workflow for more details: &#10;https://github.com/ruby/ruby-builder/blob/master/.github/workflows/build.yml&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The error message looks reasonable and is well-placed.&lt;/p&gt;&#10;&lt;p&gt;Background: My runner operates in Docker which uses a stripped-down Debian image named &lt;code&gt;node:16-bullseye&lt;/code&gt; &lt;a href="https://github.com/nektos/act/blob/master/IMAGES.md" target="_blank" rel="noopener noreferrer" class="external-link"&gt;(Referenz)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Looks like it is incompatible with the Github Actions Runner which executes atop Ubuntu22.&lt;/p&gt;&#10;&lt;p&gt;There are quite some options to resolve:&lt;/p&gt;&#10;&lt;h3 id="troubleshooting-options"&gt;Troubleshooting options&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Run the runner on Ubuntu22, use &lt;a href="https://docs.gitea.com/1.23/usage/actions/act-runner/#labels" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;labels&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for that.&lt;/li&gt;&#10;&lt;li&gt;Don&amp;rsquo;t use Docker but have the runner operate on the host&amp;rsquo;s operating system&lt;/li&gt;&#10;&lt;li&gt;Use prebuilt Ruby, installed in the runner&amp;rsquo;s cache&lt;/li&gt;&#10;&lt;li&gt;Use an out-of-the-box &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/"&gt;Jekyll-Dockerimage&lt;/a&gt; to not require additional installation steps&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="operate-the-runner-with-ubuntu22-instead-of-node16"&gt;Operate the runner with Ubuntu22 instead of Node16&lt;/h3&gt;&#10;&lt;p&gt;There is a matching &lt;a href="https://hub.docker.com/_/ubuntu" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Dockerimage&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, but it will take a lot more disk space and RAM than a minimalistic one. Plus, it takes a little more time until it is live. Let&amp;rsquo;s see whether there is a more simple solution.&lt;/p&gt;&#10;&lt;h3 id="run-the-runner-on-the-hosts-operating-system"&gt;Run the runner on the host&amp;rsquo;s operating system&lt;/h3&gt;&#10;&lt;p&gt;This has considerable security drawbacks as &lt;a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches" target="_blank" rel="noopener noreferrer" class="external-link"&gt;unprotected branches&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; might allow third-party injected runners via &lt;code&gt;git push --force&lt;/code&gt;. In an extreme case, it would blindly execute malware right on my server system. In addition, I&amp;rsquo;d kill portability by hardwiring the runner to a machine&amp;rsquo;s OS.&lt;/p&gt;&#10;&lt;h3 id="use-pre-built-ruby-in-runner-toolcache"&gt;Use pre-built Ruby in runner toolcache&lt;/h3&gt;&#10;&lt;p&gt;As proposed by the error message, I followed &lt;a href="https://about.gitea.com/resources/tutorials/enable-gitea-actions-cache-to-accelerate-cicd" target="_blank" rel="noopener noreferrer" class="external-link"&gt;another manual&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to map a volume containing prebuilt ruby on my host using Gitea&amp;rsquo;s &lt;code&gt;docker-compose.yml&lt;/code&gt;. In addition, I assigned the targeted folder to the &lt;code&gt;RUNNER_TOOL_CACHE&lt;/code&gt; environment variable:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;runner&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/act_runner:nightly&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;environment&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_INSTANCE_URL=&amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_RUNNER_NAME=&amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;RUNNER_TOOL_CACHE=/opt/hostedtoolcache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_RUNNER_REGISTRATION_TOKEN= &amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./runner/data:/data&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/opt/hostedtoolcache:/opt/hostedtoolcache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here, I map the host&amp;rsquo;s filesystem path &lt;code&gt;/opt/hostedtoolcache&lt;/code&gt; to Docker&amp;rsquo;s &lt;code&gt;/opt/hostedtoolcache&lt;/code&gt;. Docker creates the folders automatically &amp;ldquo;on both sides&amp;rdquo; if they are not yet existing on next &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Let&amp;rsquo;s try to install Ruby on the host machine.&lt;/p&gt;&#10;&lt;p&gt;So I download the &lt;a href="https://github.com/rbenv/ruby-build" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;ruby-build&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; repository via &lt;code&gt;git clone&lt;/code&gt; and have the installer run &lt;code&gt;./ruby-build/install.sh&lt;/code&gt;.&#10;Then, I execute the build with the requested target path from the error message:&lt;code&gt;ruby-build 3.1.4 /opt/hostedtoolcache/Ruby/3.1.4/x64&lt;/code&gt;&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-log" data-lang="log"&gt;==&amp;gt; Downloading openssl-3.1.4.tar.gz...&#10; % Total % Received % Xferd Average Speed Time Time Time Current&#10; Dload Upload Total Spent Left Speed&#10;100 14.8M 100 14.8M 0 0 81.3M 0 --:--:-- --:--:-- --:--:-- 81.1M&#10;==&amp;gt; Installing openssl-3.1.4...&#10;&#10;BUILD FAILED (Ubuntu 22.04 on x86_64 using ruby-build 20231114)&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="problems-with-prebuilt-ruby"&gt;Problems with prebuilt-Ruby&lt;/h3&gt;&#10;&lt;p&gt;The logs shows the problem:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;[...] No C compiler found, please specify one with the environment variable CC [...]&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So I type &lt;code&gt;apt install build-essential&lt;/code&gt; and confirm with &lt;code&gt;which gcc&lt;/code&gt; that I now have a C compiler installed at &lt;code&gt;/usr/bin/gcc&lt;/code&gt; Let&amp;rsquo;s try again!&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;crypto/comp/c_zlib.c:36:11: fatal error: zlib.h: No such file or directory&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Hmm, let&amp;rsquo;s load it via &lt;code&gt;apt install libz-dev&lt;/code&gt; and re-run the insall. This time it takes a while, then:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;*** Following extensions are not compiled:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;openssl:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Could not be configured. It will not be installed.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; /tmp/ruby-build.20231205114312.9174.0gmhNf/ruby-3.1.4/ext/openssl/extconf.rb:100: OpenSSL library could not be found. You might&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Check ext/openssl/mkmf.log &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; more details.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;readline:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Could not be configured. It will not be installed.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; /tmp/ruby-build.20231205114312.9174.0gmhNf/ruby-3.1.4/ext/readline/extconf.rb:62: Neither readline nor libedit was found&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Check ext/readline/mkmf.log &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; more details.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;*** Fix the problems, &lt;span style="color:#66d9ef"&gt;then&lt;/span&gt; remove these directories and try again &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; you want.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;[&lt;/span&gt;...&lt;span style="color:#f92672"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;OK, openssl this time. Get it with &lt;code&gt;apt install libssl-dev&lt;/code&gt; and &lt;code&gt;apt install libreadline-dev&lt;/code&gt; and finally :&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Installed ruby-3.1.4 to /opt/hostedtoolcache/Ruby/3.1.4/x64&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;!🎉!&lt;/p&gt;&#10;&lt;h2 id="still-no-success-with-the-action"&gt;Still no success with the action&lt;/h2&gt;&#10;&lt;p&gt;Strange, it is the same error all over again:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;💬 ::debug::isExplicit: 3.1.4&#10;&#10;gitea-runner-1 | [Deploy Jekyll site/build] 💬 ::debug::checking cache: /opt/hostedtoolcache/Ruby/3.1.4/x64&#10;gitea-runner-1 | [Deploy Jekyll site/build] | ::debug::checking cache: /opt/hostedtoolcache/Ruby/3.1.4/x64&#10;gitea-runner-1 | [Deploy Jekyll site/build] 💬 ::debug::not found&#10;gitea-runner-1 | [Deploy Jekyll site/build] | ::debug::not found&#10;gitea-runner-1 | [Deploy Jekyll site/build] ❗ ::error::The current runner (debian-11-x64) was detected as self-hosted&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This results from the following &lt;a href="https://github.com/ruby/setup-ruby/blob/master/ruby-builder.js#L97" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Action&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; code:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// setup-ruby/ruby-builder.js&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (&lt;span style="color:#a6e22e"&gt;common&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;shouldUseToolCache&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;engine&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;version&lt;/span&gt;)) {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;inToolCache&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;common&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;toolCacheFind&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;engine&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;version&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (&lt;span style="color:#a6e22e"&gt;inToolCache&lt;/span&gt;) {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;rubyPrefix&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;inToolCache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; } &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;toolCacheRubyPrefix&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;common&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;getToolCacheRubyPrefix&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;platform&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;engine&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;version&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (&lt;span style="color:#a6e22e"&gt;common&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;isSelfHostedRunner&lt;/span&gt;()) {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;rubyBuildDefinition&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;engine&lt;/span&gt; &lt;span style="color:#f92672"&gt;===&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;ruby&amp;#39;&lt;/span&gt; &lt;span style="color:#f92672"&gt;?&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;version&lt;/span&gt; &lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;`&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;engine&lt;/span&gt;&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;-&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;version&lt;/span&gt;&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;core&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;error&lt;/span&gt;( [...] )&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Causes for missing `toolcache`&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2024-01-06T00:00:00Z"&gt;&#10; 2024-01-06&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; There are two other possible reasons why the &lt;code&gt;toolcache&lt;/code&gt; cannot be found: The volume is made available to the runner but is not passed on to the action (see this &lt;a href="https://blog.schallbert.de/en/build-deploy-hugo-with-actions-docker-caddy/#docker-shared-volumes"&gt;post on shared volumes&lt;/a&gt;). As a result, the data is not available at the actual destination. Or the &lt;code&gt;toolcache&lt;/code&gt; path is not included in the runner&amp;rsquo;s configuration file under the &lt;code&gt;valid_volumes&lt;/code&gt; entry. According to a &lt;a href="https://gitea.com/gitea/act/pulls/60" target="_blank" rel="noopener noreferrer" class="external-link"&gt;pull request thread&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; it is auto-added, though. Still, I&amp;rsquo;m not sure if that information is outdated as I&amp;rsquo;m migrating at a phase where the runner is in active development.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h3 id="check-docker-volumes"&gt;Check docker volumes&lt;/h3&gt;&#10;&lt;p&gt;Hmpf. Let&amp;rsquo;s see whether Ruby is really available in that volume. To do this, I use &lt;code&gt;docker ps&lt;/code&gt; to get the runner&amp;rsquo;s container ID and enter it here:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker exec -it &amp;lt;containerID&amp;gt; bash&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then I navigate to &lt;code&gt;cd /opt/hostedtoolcache&lt;/code&gt;, list its contents with &lt;code&gt;ls&lt;/code&gt; and there it is: &lt;code&gt;Ruby&lt;/code&gt;. It is there but still cannot be found in the Action.&lt;/p&gt;&#10;&lt;h2 id="accept-my-failure"&gt;Accept my failure&lt;/h2&gt;&#10;&lt;p&gt;No way to proceed for me at this point 😖. I opened this problem to the community as &lt;a href="https://gitea.com/gitea/act_runner/issues/441" target="_blank" rel="noopener noreferrer" class="external-link"&gt;act_runner cannot find hostedtoolcache&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and will try&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/"&gt;Another option&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="root-cause"&gt;Root cause&lt;/h3&gt;&#10;&lt;p&gt;Looks like the support of &lt;code&gt;setup-ruby&lt;/code&gt; action for self-hosted runners like mine might have been &lt;a href="https://github.com/ruby/setup-ruby/issues/475#issuecomment-1455099634" target="_blank" rel="noopener noreferrer" class="external-link"&gt;discontinued&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Maybe the documentation, creating the above error message, has not been updated accordingly.&lt;/p&gt;&#10;</description></item><item><title>Multi-language site - l10n</title><link>https://blog.schallbert.de/en/jekyll-polyglot-language-support/</link><pubDate>Tue, 14 Nov 2023</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/jekyll-polyglot-language-support/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-11-14_translations_polyglot_flags-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Flags that can be clicked on to change display language"&#10; title="Multi-language site - l10n" /&gt;&#10;&lt;p&gt;Providing a website in different languages is called &amp;ldquo;internationalization&amp;rdquo; - in short &lt;a href="https://en.wikipedia.org/wiki/Internationalization_and_localization" target="_blank" rel="noopener noreferrer" class="external-link"&gt;i16n&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; or &amp;ldquo;localization&amp;rdquo; - &lt;code&gt;l10n&lt;/code&gt;. If you search these abbreviations in the web, you&amp;rsquo;ll find a lot of useful material. By the way, the numbers in the brief version stand for the letter count in the word&amp;rsquo;s center.&lt;/p&gt;&#10;&lt;h2 id="motivation"&gt;Motivation&lt;/h2&gt;&#10;&lt;p&gt;Right from the start, I was asked to &lt;a href="https://hobbyline.info/forum/index.php?thread/603-blog-artikel-zur-portalfr%C3%A4se/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;prepare a German version&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; of this website. To date I considered this &lt;a href="https://hobbyline.info/forum/index.php?thread/603-blog-artikel-zur-portalfr%C3%A4se/&amp;amp;postID=7619#post7619" target="_blank" rel="noopener noreferrer" class="external-link"&gt;too much effort&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and this only changed half a year ago when I was verbally asked by multiple people independently from each other.&lt;/p&gt;&#10;&lt;p&gt;So I invested half a year of evenings to get the heavy lifting done, transcribing roughly 80 articles. But I still wasn&amp;rsquo;t quite done.&lt;/p&gt;&#10;&lt;h2 id="jekyll-extension"&gt;Jekyll extension&lt;/h2&gt;&#10;&lt;p&gt;I looked at a couple of extensions for Jekyll dedicated to multilingual support like &lt;a href="https://demondehellis.github.io/en/posts/how-to-make-jekyll-blog-multilingual/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;demondehellis&amp;rsquo;s plugin-free solution&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, &lt;a href="https://github.com/untra/polyglot" target="_blank" rel="noopener noreferrer" class="external-link"&gt;untra&amp;rsquo;s polyglot plugin&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, &lt;a href="https://github.com/kurtsson/jekyll-multiple-languages-plugin" target="_blank" rel="noopener noreferrer" class="external-link"&gt;kurtsson&amp;rsquo;s jekyll-multiple-languages-plugin&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and some other solutions to publish a website in multiple languages.&lt;/p&gt;&#10;&lt;p&gt;So I had to decide for one. The plugin-less solution was out quickly because I wanted a concise naming convention where identical posts in different languages have identical names. Plus, I wanted interlinks to be persistent for the selected language. Something would have to keep language pots separated.&lt;/p&gt;&#10;&lt;p&gt;After some research, I found &lt;a href="https://leo3418.github.io/collections/multilingual-jekyll-site/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;leo3418&amp;rsquo;s&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; wonderful series of blog posts for implementing Polyglot - and so I decided to go with that one.&lt;/p&gt;&#10;&lt;h3 id="polyglot-installation"&gt;Polyglot: Installation&lt;/h3&gt;&#10;&lt;p&gt;Installation of PolyGlot is simple because both the documentation in the &lt;a href="https://github.com/untra/polyglot" target="_blank" rel="noopener noreferrer" class="external-link"&gt;repository&amp;rsquo;s&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;code&gt;readme.md&lt;/code&gt; and leo3418&amp;rsquo;s related &lt;a href="https://leo3418.github.io/collections/multilingual-jekyll-site/set-up-polyglot.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;post&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; do instruct these first steps in detail. They do this so well so I won&amp;rsquo;t lose another word about it here.&lt;/p&gt;&#10;&lt;h3 id="restructuring-the-website"&gt;Restructuring the website&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-11-14_folderstructure.jpg" alt="image: updated folder structure for this site"&gt;&lt;/figure&gt;&#10;Polyglot offers two possibilities to adapt the site&amp;rsquo;s structure for multilanguage support:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Use the language abbreviation in the post&amp;rsquo;s filename like this: &lt;code&gt;2023-11-14-jekyll-polyglot-language-support-en.md&lt;/code&gt;, repeat for each article in a different language with the appropriate abbreviation&lt;/li&gt;&#10;&lt;li&gt;Create folders named like the language code, e.g. &lt;code&gt;/en&lt;/code&gt; and put all english content in there, but keep file names the same between languages.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;I decided for the latter option out of laziness - I didn&amp;rsquo;t want to rename existing articles. Polyglot manages to stick to the same language when navigating a site. If you, for instance, click &lt;a href="https://blog.schallbert.de/en/milling-small-parts/"&gt;https://schallbert.de/milling-small-parts/&lt;/a&gt;, Polyglot will assume you want the English version to be presented and thus provide it. Where this doesn&amp;rsquo;t work anymore is section titles as they are translated themselves, invalidating attached links, so the section link &lt;a href="https://blog.schallbert.de/en/jekyll-polyglot-language-support/#restructuring-the-website"&gt;#restructuring-the-website&lt;/a&gt; will lead to a 404 fault when called in the German version.&lt;/p&gt;&#10;&lt;p&gt;Thus, when using section titles, the full post&amp;rsquo;s path has to be noted.&lt;/p&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Permalink-IDs&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-05-11T00:00:00Z"&gt;&#10; 2026-05-11&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; Alternatively, you can assign a language-independent ID to the heading so that the anchor links pointing to the headings remain identical. Example: &lt;code&gt;## Restructuring the website {#website-restructuring}&lt;/code&gt;, accessed via the link &lt;code&gt;#website-restructuring&lt;/code&gt;.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h3 id="translating-page-title-navigation-header-footer"&gt;Translating page title, navigation, header, footer&lt;/h3&gt;&#10;&lt;p&gt;This is getting a little more complicated. Jekyll&amp;rsquo;s &lt;code&gt;_config.yml&lt;/code&gt; is intended for one version of the website only, so we cannot just create two variants of this file and expect it to work. To still enable full translation, we&amp;rsquo;ll have to overwrite language sensitive parameters like &lt;code&gt;site.title&lt;/code&gt; or &lt;code&gt;site.description&lt;/code&gt; externally depending on selected locale.&lt;/p&gt;&#10;&lt;p&gt;I have solved this adding &lt;code&gt;l10n.yml&lt;/code&gt; files in the &lt;code&gt;_data&lt;/code&gt; folder that covers these parameters for each language, and then packing them into a language-code folder like we&amp;rsquo;re used to do with our posts. To show their content, the relevant liquid parameters in &lt;code&gt;html&lt;/code&gt; files (&lt;code&gt;_includes&lt;/code&gt; folder) have to be modified to now search there instead of taking the standard config. Example: &lt;code&gt;{{ site.data.ui-text[site.data.l10n.locale].follow_label | remove: &amp;quot;:&amp;quot; | default: &amp;quot;Follow&amp;quot; }}&lt;/code&gt;. German version of this site will show &lt;strong&gt;FOLGEN:&lt;/strong&gt; while the English one says &lt;strong&gt;FOLLOW:&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;h3 id="language-selector"&gt;Language selector&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-11-14_translations_polyglot_flags.jpg" alt="image: footer section of this website"&gt;&lt;/figure&gt;&#10;Of course I want to offer the option to select a locale on every page. To do this, I added the language code and its flag to the footer. On click, it will reload the page in the requested locale. The following code I wrote in Liquid enables this behavior:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c" data-lang="c"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&lt;span style="color:#f92672"&gt;%&lt;/span&gt; raw &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&lt;span style="color:#f92672"&gt;%&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; lang in site.languages &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign url_parts &lt;span style="color:#f92672"&gt;=&lt;/span&gt; page.url &lt;span style="color:#f92672"&gt;|&lt;/span&gt; split: &lt;span style="color:#e6db74"&gt;&amp;#34;/&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign lang_code &lt;span style="color:#f92672"&gt;=&lt;/span&gt; lang &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign collection_name &lt;span style="color:#f92672"&gt;=&lt;/span&gt; url_parts[&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;] &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign post_url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; url_parts &lt;span style="color:#f92672"&gt;|&lt;/span&gt; last &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; lang &lt;span style="color:#f92672"&gt;==&lt;/span&gt; site.default_lang &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; collection_name &lt;span style="color:#f92672"&gt;==&lt;/span&gt; post_url &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign lang_url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: post_url &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign lang_url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: collection_name &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: &lt;span style="color:#e6db74"&gt;&amp;#34;/&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: post_url &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; endif &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; url_parts.size &lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt; &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign lang_url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;/&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: lang_code &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: &lt;span style="color:#e6db74"&gt;&amp;#34;/&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; collection_name &lt;span style="color:#f92672"&gt;==&lt;/span&gt; post_url &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign lang_url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; lang_url &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: post_url &lt;span style="color:#f92672"&gt;%&lt;/span&gt;} &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt; &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; assign lang_url &lt;span style="color:#f92672"&gt;=&lt;/span&gt; lang_url &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: collection_name &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: lang_url &lt;span style="color:#f92672"&gt;|&lt;/span&gt; append: post_url &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; endif &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#f92672"&gt;%&lt;/span&gt; endif &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;&lt;/span&gt;a href&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34; {{ lang_url }}&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt;{{ site.data[lang].l10n.lang_name }}&lt;span style="color:#f92672"&gt;&amp;lt;/&lt;/span&gt;a&lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&lt;span style="color:#f92672"&gt;%&lt;/span&gt; endfor &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&lt;span style="color:#f92672"&gt;%&lt;/span&gt; endraw &lt;span style="color:#f92672"&gt;%&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here I&amp;rsquo;m splitting the current page &lt;code&gt;url&lt;/code&gt;. The last section will be the &lt;code&gt;post_url&lt;/code&gt;. The algo assumes that the first part is the post&amp;rsquo;s collection name, e.g. &lt;code&gt;posts-hardware&lt;/code&gt;. The following logic inserts the language code into the url if and where needed so that the correct full url is built depending on the used collection and page navigation depth.&lt;/p&gt;&#10;&lt;h2 id="publishing"&gt;Publishing&lt;/h2&gt;&#10;&lt;p&gt;I was pretty proud of this elegant way of selecting languages. I thing it all fits in nicely and the multi-language support potentially adds a good number of readers. The big downer is the duplicated work for creating each post in two languages.&lt;/p&gt;&#10;&lt;p&gt;Unfortunately, the site now wasn&amp;rsquo;t compatible anymore with Github Pages - the auto release would show each page twice instead of keeping them separated by language. Two options remained: &lt;a href="https://blog.schallbert.de/en/struggling-github-actions/"&gt;creating custom Github Actions&lt;/a&gt; to publish on Github Pages, or to &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;move the whole site to an own server&lt;/a&gt;. I&amp;rsquo;ll decide for the latter to be on the safe side regarding data protection regulations because in that case I can make sure the hoster is situated in Germany.&lt;/p&gt;&#10;&lt;p&gt;Well, this upcoming project is too big to take on in this post.&lt;/p&gt;&#10;</description></item><item><title>Github Actions: Troubleshooting</title><link>https://blog.schallbert.de/en/struggling-github-actions/</link><pubDate>Tue, 14 Feb 2023</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/struggling-github-actions/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-02-14_github_actions-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Github Actions Workflow"&#10; title="Github Actions: Troubleshooting" /&gt;&#10;&lt;h2 id="motivation"&gt;Motivation&lt;/h2&gt;&#10;&lt;p&gt;Why I&amp;rsquo;m writing a short post about Github Actions? Because I wanted to use it for my latest Software-project, &lt;a href="https://blog.schallbert.de/en/projects/qr-codengrave/"&gt;QR-codengrave&lt;/a&gt;. When at some distant point in the future, I have a new computer, don&amp;rsquo;t remember which IDE I used to build and run, test, and create assets with. Or, say, I have a corrupted virtual environment or &lt;code&gt;launch.json&lt;/code&gt;, I still want to be able to deploy that application, publish bugfixes or bump a release.&lt;/p&gt;&#10;&lt;p&gt;Although that&amp;rsquo;s not the usual argument for using CI/CD - typically the first reason being that collaboration on a piece of software becomes easier - still the appeal is strong enough to try Github&amp;rsquo;s automation called &lt;a href="https://docs.github.com/en/actions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github Actions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;I won&amp;rsquo;t try to run nightlys or have a continuous deployment pipeline as an end in itself, though, and only want an automated build, lint, test run, and deploy stuff when merging back to the production branch.&lt;/p&gt;&#10;&lt;h3 id="got-it-but-why-a-dedicated-post"&gt;Got it, but why a dedicated post?&lt;/h3&gt;&#10;&lt;p&gt;Because it was such a pain to get it right. I spent hours and hours pushing and hoping that, this time, the goddess of Github Actions would actually have my solution built without errors and at least run some of the tests.&lt;/p&gt;&#10;&lt;p&gt;Note: If I had been more diligent, I would have installed yet another tool &lt;a href="https://github.com/nektos/act" target="_blank" rel="noopener noreferrer" class="external-link"&gt;like this&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to have my actions run locally, both saving time and the embarassment of tens of failed builds in a row. But I wasn&amp;rsquo;t.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-02-14_gh_actions_struggle.jpg" alt="Image: Github Action failed runs"&gt;&lt;/figure&gt;&#10;&lt;p&gt;I had quite some errors that appeared on the way of making QR-codengrave. Some I wasn&amp;rsquo;t even able to properly resolve so I had to utilize work-arounds. But let&amp;rsquo;s get to that later.&lt;/p&gt;&#10;&lt;h2 id="the-script"&gt;The script&lt;/h2&gt;&#10;&lt;p&gt;Github Actions uses &lt;code&gt;YAML&lt;/code&gt; to take orders for its pipelines. It is very well &lt;a href="https://docs.github.com/en/actions/learn-github-actions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;documented&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and provides many &lt;a href="https://github.com/marketplace?type=actions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;readily-working scripts&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for most scenarios and programming languages.&lt;/p&gt;&#10;&lt;p&gt;The so-called &amp;ldquo;Workflow file&amp;rdquo; looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-YAML" data-lang="YAML"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# This workflow will install Python dependencies, run tests and lint with a single version of Python&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;python_integrate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;on&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;push&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;branches&lt;/span&gt;: [ &lt;span style="color:#e6db74"&gt;&amp;#34;master&amp;#34;&lt;/span&gt; ]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;pull_request&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;branches&lt;/span&gt;: [ &lt;span style="color:#e6db74"&gt;&amp;#34;master&amp;#34;&lt;/span&gt; ]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;permissions&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;contents&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;read&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;jobs&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;build&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;runs-on&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;ubuntu-latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;steps&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/checkout@v3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Set up Python 3.10&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;actions/setup-python@v3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;with&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;python-version&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;3.10&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Install dependencies&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: |&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; python -m pip install --upgrade pip&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; pip install flake8 pytest&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; pip install qrcodegen # Dependency install of qrcodegen&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; if [ -f requirements.txt ]; then pip install -r requirements.txt; fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Lint with flake8&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: |&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; # stop the build if there are Python syntax errors or undefined names&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Test with pytest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;uses&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;GabrielBB/xvfb-action@v1 &lt;/span&gt; &lt;span style="color:#75715e"&gt;# Diverts tkinter GUI to a virtual frame buffer (VFB)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;with&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;run&lt;/span&gt;: |&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; pytest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And it does not divert too much from the standard template that I was using first. The only things I added are the run &lt;code&gt;pytest&lt;/code&gt; entry and the usage of a virtual frame buffer to cirumvent issues with my GUI which makes the tests fail when the GUI tries to fire up and there&amp;rsquo;s no screen to show it on.&lt;/p&gt;&#10;&lt;h2 id="github-action-runner-errors"&gt;Github Action runner errors&lt;/h2&gt;&#10;&lt;p&gt;The following list of fails describe the errors and resolutions that I faced until I had a stable CI at around action run #50.&lt;/p&gt;&#10;&lt;h3 id="directory-mismatch"&gt;Directory mismatch?&lt;/h3&gt;&#10;&lt;p&gt;&lt;em&gt;Output&lt;/em&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;opt&lt;span style="color:#f92672"&gt;/&lt;/span&gt;hostedtoolcache&lt;span style="color:#f92672"&gt;/&lt;/span&gt;Python&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;3.10.9&lt;/span&gt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;x64&lt;span style="color:#f92672"&gt;/&lt;/span&gt;lib&lt;span style="color:#f92672"&gt;/&lt;/span&gt;python3&lt;span style="color:#ae81ff"&gt;.10&lt;/span&gt;&lt;span style="color:#f92672"&gt;/&lt;/span&gt;importlib&lt;span style="color:#f92672"&gt;/&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;__init__&lt;/span&gt;&lt;span style="color:#f92672"&gt;.&lt;/span&gt;py:&lt;span style="color:#ae81ff"&gt;126&lt;/span&gt;: &lt;span style="color:#f92672"&gt;in&lt;/span&gt; import_module&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; _bootstrap&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_gcd_import(name[level:], package, level)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;test&lt;span style="color:#f92672"&gt;/&lt;/span&gt;test_machinify_vector&lt;span style="color:#f92672"&gt;.&lt;/span&gt;py:&lt;span style="color:#ae81ff"&gt;4&lt;/span&gt;: &lt;span style="color:#f92672"&gt;in&lt;/span&gt; &lt;span style="color:#f92672"&gt;&amp;lt;&lt;/span&gt;module&lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;from&lt;/span&gt; bin.platform.machinify_vector &lt;span style="color:#f92672"&gt;import&lt;/span&gt; MachinifyVector, Tool, EngraveParams&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;E &lt;span style="color:#a6e22e"&gt;ModuleNotFoundError&lt;/span&gt;: No module named &lt;span style="color:#e6db74"&gt;&amp;#39;bin&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;em&gt;Reason&lt;/em&gt;:&#10;I placed my source files under &lt;code&gt;/bin&lt;/code&gt; and made it available to a local instance of PyInstaller (the tool I chose to render my python sources into an executable under Windows). Upon upload, Github Actions couldn&amp;rsquo;t relate to the paths I chose and had that error prepared for me.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;Resolution:&lt;/em&gt;&#10;Add an empty file with name &lt;code&gt;__init__.py&lt;/code&gt; into the &lt;code&gt;/bin&lt;/code&gt; folder. This will flag anything in that folder as package, thus making it available to GH actions.&#10;So what looked like a directory mismatch actually was a package-not-found error that my IDE didn&amp;rsquo;t have as it knew which files I had created.&lt;/p&gt;&#10;&lt;h3 id="yaml-syntax-errors"&gt;YAML syntax errors&lt;/h3&gt;&#10;&lt;p&gt;This error occurs because I was trying to put two workflows into one file. Somehow Github Actions seems to only accept a single one per file.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-02-14_gh_actions_syntax.jpg" alt="Image: Github Action syntax error"&gt;&lt;/figure&gt;&#10;&lt;h3 id="path-not-found-errors"&gt;Path-not-found errors&lt;/h3&gt;&#10;&lt;p&gt;I had a lot of those and they were painful to resolve. The folder structure that I was targeting looked like:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-YAML" data-lang="YAML"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#ae81ff"&gt;assets &lt;/span&gt; &lt;span style="color:#75715e"&gt;# images, persistence file, etc.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#ae81ff"&gt;src &lt;/span&gt; &lt;span style="color:#75715e"&gt;# source files&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#ae81ff"&gt;test &lt;/span&gt; &lt;span style="color:#75715e"&gt;# pytest files for unit and integration testing&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#ae81ff"&gt;dist &lt;/span&gt; &lt;span style="color:#75715e"&gt;# build artifacts&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;- &lt;span style="color:#ae81ff"&gt;build &lt;/span&gt; &lt;span style="color:#75715e"&gt;# build process files&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But somehow, it was super hard to make my local IDE build environment, Pyinstaller, and the Github Actions Worker and its trigger of Pyinstaller to cooperate. One of those four parties would always complain that a path is missing or something else was wrong. That&amp;rsquo;s why I decided to abandon relative paths and instead use python&amp;rsquo;s &lt;code&gt;importlib_resources&lt;/code&gt;. But in the end, this turned out to be problematic with Pyinstuller on remote Github Actions.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-02-14_gh_actions_paths.jpg" alt="Image: Github Action relative path error"&gt;&lt;/figure&gt;&#10;&lt;p&gt;What finally solved the problem was to move &lt;code&gt;assets&lt;/code&gt; into &lt;code&gt;src&lt;/code&gt;. This way, the &lt;code&gt;./&lt;/code&gt; command couldn&amp;rsquo;t go wrong anywhere and although I don&amp;rsquo;t like the construct very much, I was tired of putting more time into that for a clean fix (If you know how to handle path pointing in Python for both local and remote, let me know in the &lt;a href="https://github.com/Schallbert/schallbert.github.io/discussions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;discussions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="tkinter-headless-testing"&gt;Tkinter headless testing&lt;/h3&gt;&#10;&lt;p&gt;Later in the development process, I decided to add some integration tests into my solution. This way I wanted to make sure that child windows would actually perform the callbacks to update the main application, and vice versa e.g. that the persisted tool list is cascaded into the tool configuration window.&lt;/p&gt;&#10;&lt;p&gt;These tests were running locally but with a disadvantage: When I simulated an error or warning case, the corresponding &lt;code&gt;messageBox&lt;/code&gt; would insist to be closed manually by the user before continuing the tests. Which was just a bit annoying for me in the IDE to do, but would just be impossible to do from within Github Action&amp;rsquo;s CI pipeline.&lt;/p&gt;&#10;&lt;p&gt;So I bit the bullet and added a wrapper class to tkinter&amp;rsquo;s &lt;code&gt;messageBox&lt;/code&gt; so I could inject a mock that wouldn&amp;rsquo;t actually trigger the popup:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;MsgBox&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&amp;#34;Re-implementation due to testing purposes: With this trick, we are able to mock these windows&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; so we do not have to wait for users to manually close the dialog, unblocking the application again.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;showinfo&lt;/span&gt;(self, title, message):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; showinfo(title&lt;span style="color:#f92672"&gt;=&lt;/span&gt;title, message&lt;span style="color:#f92672"&gt;=&lt;/span&gt;message)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;error&lt;/span&gt;(self, title, message):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; showerror(title&lt;span style="color:#f92672"&gt;=&lt;/span&gt;title, message&lt;span style="color:#f92672"&gt;=&lt;/span&gt;message)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Although in theory it should be possible also to invoke the popup&amp;rsquo;s &amp;ldquo;OK button&amp;rdquo; from within the test, I wasn&amp;rsquo;t able to have that automated reliably.&lt;/p&gt;&#10;&lt;p&gt;When this worked, I pushed to Github with high expectations - and got another beautiful error:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;_tkinter.TclError: no display name and no $DISPLAY environment variable&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;At least that was easy to understand: Tkinter just didn&amp;rsquo;t know where to draw the windows - seems legit when there&amp;rsquo;s no display connected.&lt;/p&gt;&#10;&lt;p&gt;So I searched the internet and found a single line of code that miraculously solved my problem by introducing a virtual frame buffer:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;uses: GabrielBB/xvfb-action@v1 # Diverts tkinter GUI to a virtual frame buffer (VFB)&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;Now, on Push, Github Actions rained green ticks down on me which felt really good for a change.&lt;/p&gt;&#10;</description></item><item><title>QR-codengrave V1.1 / V1.2</title><link>https://blog.schallbert.de/en/qr-codengrave1-1/</link><pubDate>Sun, 12 Feb 2023</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/qr-codengrave1-1/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-02-12_qr-codengrave-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Splash screen of QR-codengrave V1.1"&#10; title="QR-codengrave V1.1 / V1.2" /&gt;&#10;&lt;h2 id="in-a-nutshell"&gt;In a nutshell&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Faster Engrave algorithm&lt;/li&gt;&#10;&lt;li&gt;Several bugfixes&lt;/li&gt;&#10;&lt;li&gt;Usability improvements&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="demo-video"&gt;Demo video&lt;/h2&gt;&#10;&lt;figure class="media-frame media-frame--center media-frame--video"&gt;&#10; &lt;div class="media-video"&gt;&lt;iframe&#10; src="https://makertube.net/videos/embed/jFD7nxftVHjUy6LKPCFE3N"&#10; title="QR-codengrave Quick Demo"&#10; loading="lazy"&#10; allow="autoplay; fullscreen; picture-in-picture"&#10; allowfullscreen&#10; style="width: 100%; height: auto; min-height: 315px; aspect-ratio: 16/9;"&#10; &gt;&lt;/iframe&gt;&lt;/div&gt;&#10; &lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;QR-codengrave Quick Demo&lt;/span&gt;&lt;a href="https://makertube.net/videos/embed/jFD7nxftVHjUy6LKPCFE3N" class="attr-link" target="_blank" rel="noopener noreferrer"&gt;↗&lt;/a&gt;&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;h2 id="version-11"&gt;Version 1.1&lt;/h2&gt;&#10;&lt;h3 id="engrave-algorithm-upgrade"&gt;Engrave algorithm upgrade&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The algorithm now scans the lines horizontally&lt;/li&gt;&#10;&lt;li&gt;One line &amp;ldquo;Left-to-Right&amp;rdquo;, next line &amp;ldquo;Right-to-Left&amp;rdquo; etc. to reduce machine travel&lt;/li&gt;&#10;&lt;li&gt;prefers lines over dots, and longer lines over shorter ones for time optimization&lt;/li&gt;&#10;&lt;li&gt;draws lines vertically or horizontally&lt;/li&gt;&#10;&lt;li&gt;rapid move positioning now is packed into vectors that can be called to new Points(X, Y) simultaneously, not only one dimension at a time&lt;/li&gt;&#10;&lt;li&gt;New algorithm just needs 80% of the time for engraving compared to the old one&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2023-02-12_qr-codengrave_algo.jpg" alt="Image: Algorithm comparison 1.0 vs. 1.1"&gt;&lt;/figure&gt;&#10;&lt;h3 id="bugfixes"&gt;Bugfixes&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;splash image (&lt;code&gt;qruwu.jpg&lt;/code&gt;) is not shown on startup when &lt;code&gt;Persistence.dat&lt;/code&gt; is not available&lt;/li&gt;&#10;&lt;li&gt;Crash when pressing &lt;code&gt;Stop Draw&lt;/code&gt; although drawing has never been initiated before&lt;/li&gt;&#10;&lt;li&gt;Crash when adding a tool on an empty selecion list&lt;/li&gt;&#10;&lt;li&gt;Initialization of application with no tool selected disables dropdown menu&lt;/li&gt;&#10;&lt;li&gt;Fix a calculation error in QR-code size estimation&lt;/li&gt;&#10;&lt;li&gt;Fix an error pixel width calculation for tapered tools&lt;/li&gt;&#10;&lt;li&gt;Actually the currently selected tool is not shown &amp;ldquo;selected&amp;rdquo; in the dropdown on startup&lt;/li&gt;&#10;&lt;li&gt;QR-codes can grow larger than the display area&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="usability-improvements"&gt;Usability improvements&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Change default tool name from &lt;code&gt;name&lt;/code&gt; to &lt;code&gt;default&lt;/code&gt; for clarity&lt;/li&gt;&#10;&lt;li&gt;Make non-tapered (V-cut) tool the default tool for better usability&lt;/li&gt;&#10;&lt;li&gt;Setting a new XY-zero offset became more intuitive (Y down is negative, X right is positive)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="version-12"&gt;Version 1.2&lt;/h2&gt;&#10;&lt;p&gt;Minor change over 1.1, most importantly a Bugfix that could lead to tool change macro not being called.&#10;In addition, tapered tool angles are now taken into account when calculating track width and QR-code engrave size.&lt;/p&gt;&#10;&lt;h3 id="bugfixes-1"&gt;Bugfixes&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Tool select &lt;code&gt;Tx&lt;/code&gt; and tool change command &lt;code&gt;M06&lt;/code&gt; are now in the same line of the G-code to correctly trigger tool change macros&lt;/li&gt;&#10;&lt;li&gt;Actual selected tool not shown on startup, instead the first tool of the list was shown&lt;/li&gt;&#10;&lt;li&gt;QR-code drawing could grow larger than the screen. This is fixed now.&lt;/li&gt;&#10;&lt;li&gt;Crash fix when trying to update status although no engrave parameters available&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="usability-improvements-1"&gt;Usability improvements&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Add application version to G-code as comment&lt;/li&gt;&#10;&lt;li&gt;Add &lt;code&gt;qr_&lt;/code&gt; prefix to G-code save-to dialog&amp;rsquo;s default name&lt;/li&gt;&#10;&lt;li&gt;Improve job duration estimate&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="availability"&gt;Availability&lt;/h2&gt;&#10;&lt;p&gt;The latest release is available for download &lt;a href="https://github.com/Schallbert/QR-codengrave/releases" target="_blank" rel="noopener noreferrer" class="external-link"&gt;here&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, free of charge for home and private use. For more info on the project, please find the &lt;a href="https://github.com/Schallbert/QR-codengrave" target="_blank" rel="noopener noreferrer" class="external-link"&gt;readme&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and the &lt;a href="https://blog.schallbert.de/en/projects/qr-codengrave/"&gt;background information&lt;/a&gt; linked here.&lt;/p&gt;&#10;&lt;h2 id="migration-from-an-earlier-version"&gt;Migration from an earlier version&lt;/h2&gt;&#10;&lt;p&gt;It is possible to copy the persistence file to the new version&amp;rsquo;s directory. This way, saved tool lists, engrave parameters, and XY0 offsets can be ported to the new version. How it&amp;rsquo;s done:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Navigate into &lt;code&gt;src/assets&lt;/code&gt; of your QR-codengrave folder.&lt;/li&gt;&#10;&lt;li&gt;copy &lt;code&gt;Persistence.dat&lt;/code&gt; to a temporary folder or e.g. your Desktop.&lt;/li&gt;&#10;&lt;li&gt;Remove the current installation by deleting the folder.&lt;/li&gt;&#10;&lt;li&gt;Download the latest release, move it where your previous release was deployed.&lt;/li&gt;&#10;&lt;li&gt;Move the &lt;code&gt;Persistence.dat&lt;/code&gt; into &lt;code&gt;src/assets&lt;/code&gt; of your new installation and start the application.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Convert videos with VLC player</title><link>https://blog.schallbert.de/en/video-conversion/</link><pubDate>Wed, 27 Oct 2021</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/video-conversion/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2021-10-27_vlc-thumb.jpg"&#10; class="post-cover"&#10; alt="VLC media player icon"&#10; title="Convert videos with VLC player" /&gt;&#10;&lt;h2 id="website-media-creation"&gt;Website: media creation&lt;/h2&gt;&#10;&lt;p&gt;For this website, I have been converting media files a lot. You know, the photos I shoot are really big which leads to long page load times and screen-filling images. While it is quite easy to reduce their size, and increase JPEG compression, it is a lot harder to do so with videos.&lt;/p&gt;&#10;&lt;h2 id="the-problem"&gt;The problem&lt;/h2&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2021-10-27_wtf-vlc.jpg" alt="Image: VLC&amp;#39;s preview Effects and filters window"&gt;&lt;/figure&gt;&#10;WTF, VLC?!&#10;I&amp;rsquo;m using the popular &lt;a href="https://www.videolan.org/vlc/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;vlc player&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and somehow I didn&amp;rsquo;t seem to get it right. Either my video doesn&amp;rsquo;t turn out in greyscale as I planned, the output file is empty (Use &lt;code&gt;H.264&lt;/code&gt;, instead of &lt;code&gt;H.265&lt;/code&gt; to fix), the aspect ratio seems distorted, or the video suddenly has rotated which I neither wanted nor expected to happen.&lt;/p&gt;&#10;&lt;h2 id="items-to-cover"&gt;Items to cover&lt;/h2&gt;&#10;&lt;p&gt;This series of steps help me (and maybe you, too) how to make simple video conversions like&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://blog.schallbert.de/en/video-conversion/#colors"&gt;Change or remove colors&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://blog.schallbert.de/en/video-conversion/#rotation"&gt;Rotate a video&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://blog.schallbert.de/en/video-conversion/#cropping"&gt;Crop video, update aspect ratio and resolution&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://blog.schallbert.de/en/video-conversion/#cutting"&gt;Cut / Trim a video&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://blog.schallbert.de/en/video-conversion/#scaling"&gt;Change video resolution / size&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="how-does-the-filter-stuff-work"&gt;How does the filter stuff work?&lt;/h2&gt;&#10;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2021-10-27_video-effects.jpg" alt="Image: VLC&amp;#39;s preview Effects and filters window"&gt;&lt;/figure&gt;&#10;&lt;h3 id="preview-filters"&gt;Preview filters&lt;/h3&gt;&#10;&lt;p&gt;VLC has a quick-preview feature for the most common filters in the &lt;code&gt;Tools -&amp;gt; Effects and filters&lt;/code&gt; menu. This directly affects the video you&amp;rsquo;re viewing, but won&amp;rsquo;t affect your saved file. It&amp;rsquo;s just meant for you previewing and controlling the effect properties these filters will have on your video. View &lt;a href="https://www.videoproc.com/troubleshoot-vlc/how-to-crop-a-video-in-vlc.htm" target="_blank" rel="noopener noreferrer" class="external-link"&gt;this VideoProc blog entry&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for in-depth guides - and - more importantly - many images stepping you through. For the impatient fraction: continue reading 😉&lt;/p&gt;&#10;&lt;h3 id="save-filtered-video-to-file"&gt;Save filtered video to file&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2021-10-27_profile.jpg" alt="Image: VLC&amp;#39;s conversion profile window for saving"&gt;&lt;/figure&gt;&#10;To save a filter that you selected for your preview, you must add them to the conversion profile. As long as they remain ticked, &lt;em&gt;any&lt;/em&gt; save with this profile will modify your video, eventually even although you unticked all changes you made to the preview filters in the past!&lt;/p&gt;&#10;&lt;h2 id="colors"&gt;How to change colors in my video?&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Open the video file you want to modify.&lt;/li&gt;&#10;&lt;li&gt;Click &lt;code&gt;Tools -&amp;gt; Effects and filters&lt;/code&gt; (or press &lt;code&gt;Ctrl+E&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;A dialog with three tabs opens. Select the &lt;code&gt;Video Effects&lt;/code&gt; tab.&lt;/li&gt;&#10;&lt;li&gt;Under &lt;code&gt;Essentials&lt;/code&gt;, tick &lt;code&gt;Image Adjust&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Make your changes and hit &lt;code&gt;Save&lt;/code&gt;, then &lt;code&gt;Close&lt;/code&gt;. A preview of the effect is shown in the main window.&lt;/li&gt;&#10;&lt;li&gt;Open the playlist view by pressing &lt;code&gt;Toggle Playlist&lt;/code&gt; (&lt;code&gt;Ctrl+L&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;Right-click your video, select &lt;code&gt;Save...&lt;/code&gt; in the context menu.&lt;/li&gt;&#10;&lt;li&gt;Press the wrench symbol &lt;code&gt;Edit selected Profile&lt;/code&gt; and in the &lt;code&gt;Video codec&lt;/code&gt; tab, select the &lt;code&gt;Filters&lt;/code&gt; sub-tab.&lt;/li&gt;&#10;&lt;li&gt;Tick the &lt;code&gt;Image properties filter&lt;/code&gt;. This will make your change permanent once you save your file.&lt;/li&gt;&#10;&lt;li&gt;Enter a destination file and hit &lt;code&gt;start&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Don&amp;rsquo;t forget to untick the &lt;code&gt;Image properties filter&lt;/code&gt; in your profile. Also rewind the changes you made in the &lt;code&gt;Video Effects&lt;/code&gt; tab (step 4).&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="rotation"&gt;How to rotate my video?&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Open the video file you want to modify.&lt;/li&gt;&#10;&lt;li&gt;Click &lt;code&gt;Tools -&amp;gt; Effects and filters&lt;/code&gt; (or press &lt;code&gt;Ctrl+E&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;A dialog with three tabs opens. Select the &lt;code&gt;Video Effects&lt;/code&gt; tab.&lt;/li&gt;&#10;&lt;li&gt;Under &lt;code&gt;Geometry&lt;/code&gt;, tick &lt;code&gt;Transform&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Make your changes and hit &lt;code&gt;Save&lt;/code&gt;, then &lt;code&gt;Close&lt;/code&gt;. A preview of the effect is shown in the main window.&lt;/li&gt;&#10;&lt;li&gt;Open the playlist view by pressing &lt;code&gt;Toggle Playlist&lt;/code&gt; (&lt;code&gt;Ctrl+L&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;Right-click your video, select &lt;code&gt;Save...&lt;/code&gt; in the context menu.&lt;/li&gt;&#10;&lt;li&gt;Press the wrench symbol &lt;code&gt;Edit selected Profile&lt;/code&gt; and in the &lt;code&gt;Video codec&lt;/code&gt; tab, select the &lt;code&gt;Filters&lt;/code&gt; sub-tab.&lt;/li&gt;&#10;&lt;li&gt;Tick the &lt;code&gt;Video transformation filter&lt;/code&gt;. This will make your change permanent once you save your file.&lt;/li&gt;&#10;&lt;li&gt;Enter a destination file and hit &lt;code&gt;start&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Don&amp;rsquo;t forget to untick the &lt;code&gt;Image properties filter&lt;/code&gt; in your profile. Also rewind the changes you made in the &lt;code&gt;Video Effects&lt;/code&gt; tab.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="cropping"&gt;How to crop my video?&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Open the video file you want to modify.&lt;/li&gt;&#10;&lt;li&gt;Click &lt;code&gt;Tools -&amp;gt; Effects and filters&lt;/code&gt; (or press &lt;code&gt;Ctrl+E&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;A dialog with three tabs opens. Select the &lt;code&gt;Video Effects&lt;/code&gt; tab.&lt;/li&gt;&#10;&lt;li&gt;Under &lt;code&gt;Crop&lt;/code&gt;, play around with the pixel values and watch the preview window carefully until you&amp;rsquo;re happy with the crop. Note or memorize the settings you&amp;rsquo;d like to apply. Close the dialogue.&lt;/li&gt;&#10;&lt;li&gt;Click &lt;code&gt;Tools -&amp;gt; Preferences&lt;/code&gt; (or press &lt;code&gt;Ctrl+P&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;On the &lt;code&gt;Interface&lt;/code&gt; (start) Tab, select the radioButton &lt;code&gt;Show Settings - All&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Scroll down to the &lt;code&gt;Video&lt;/code&gt; element and open &lt;code&gt;Filters&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Select the &lt;code&gt;Video cropping filter&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;find the &lt;code&gt;Cropadd&lt;/code&gt; element within the filters list on the left. Enter the values you memorized/ noted down in the preview. Hit &lt;code&gt;Save.&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Open the playlist view by pressing &lt;code&gt;Toggle Playlist&lt;/code&gt; (&lt;code&gt;Ctrl+L&lt;/code&gt;). Then Right-click your video, select &lt;code&gt;Save...&lt;/code&gt; in the context menu.&lt;/li&gt;&#10;&lt;li&gt;Press the wrench symbol &lt;code&gt;Edit selected Profile&lt;/code&gt; and in the &lt;code&gt;Video codec&lt;/code&gt; tab, select the &lt;code&gt;Filters&lt;/code&gt; sub-tab.&lt;/li&gt;&#10;&lt;li&gt;Tick the &lt;code&gt;Video crop filter&lt;/code&gt;. This will make your change permanent once you save your file.&lt;/li&gt;&#10;&lt;li&gt;Enter a destination file and hit &lt;code&gt;start&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Don&amp;rsquo;t forget to untick the &lt;code&gt;Video crop filter&lt;/code&gt; in your profile. Also rewind the changes you made in the &lt;code&gt;Filters&lt;/code&gt; preferences item.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="cutting"&gt;How to trim my video?&lt;/h2&gt;&#10;&lt;p&gt;Note: A more detailed version of this can be found in &lt;a href="https://www.movavi.io/how-to-trim-video-in-vlc-en/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;this post&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Open the video file you want to modify.&lt;/li&gt;&#10;&lt;li&gt;Show Record button in VLC Player (tick &lt;code&gt;View-&amp;gt;Advanced Controls&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;Jump to where you want the trimmed video to begin.&lt;/li&gt;&#10;&lt;li&gt;Press the red &lt;code&gt;record&lt;/code&gt; button&lt;/li&gt;&#10;&lt;li&gt;Play the video sequence. Stop where you want to end your trim.&lt;/li&gt;&#10;&lt;li&gt;VLC&amp;rsquo;s standard location for video output: &lt;code&gt;C:\Users\Username\Video&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="scaling"&gt;How to change video resolution?&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Open the video file you want to modify.&lt;/li&gt;&#10;&lt;li&gt;Open the playlist view by pressing &lt;code&gt;Toggle Playlist&lt;/code&gt; (&lt;code&gt;Ctrl+L&lt;/code&gt;). Then Right-click your video, select &lt;code&gt;Save...&lt;/code&gt; in the context menu.&lt;/li&gt;&#10;&lt;li&gt;Press the wrench symbol &lt;code&gt;Edit selected Profile&lt;/code&gt; and in the &lt;code&gt;Video codec&lt;/code&gt; tab, select the &lt;code&gt;Resolution&lt;/code&gt; sub-tab.&lt;/li&gt;&#10;&lt;li&gt;Enter the video size you&amp;rsquo;d like to convert to and hit &lt;code&gt;Save&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Hope this helps!&lt;/p&gt;&#10;</description></item><item><title>Paginator broken, Favorite icon placed</title><link>https://blog.schallbert.de/en/jekyll-icon-paginator/</link><pubDate>Sun, 13 Jun 2021</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/jekyll-icon-paginator/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/favicon-thumb.jpg"&#10; class="post-cover"&#10; alt="favicon implementation on a website"&#10; title="Paginator broken, Favorite icon placed" /&gt;&#10;&lt;h3 id="adding-a-tab-icon---favicon"&gt;Adding a &amp;ldquo;tab&amp;rdquo; icon - favicon&lt;/h3&gt;&#10;&lt;p&gt;I was worrying about a strange error message that Jekyll threw at any page reload since some pushes: &lt;code&gt;[2021-06-13 22:07:43] ERROR '/favicon.ico' not found.&lt;/code&gt; Some online research revealed that this is neither a Jekyll, nor a Theme error, but a missing file error. See &lt;a href="https://blog.schallbert.de/en/projects/thissite/#favicon"&gt;here&lt;/a&gt; how I implemented the icon for this page.&lt;/p&gt;&#10;&lt;h3 id="pagination-again"&gt;Pagination, again&lt;/h3&gt;&#10;&lt;p&gt;I found out that somehow, the pagination doesn&amp;rsquo;t even work on my page while the layouting assumes that it&amp;rsquo;s preparing the pages. Now, if I try to access the page by pressing the &amp;ldquo;previous&amp;rdquo; button, I get a 404 error. Till now, I wasn&amp;rsquo;t able to fix this because the pagination fails silently and I do not know where.&#10;Switching it off completely for now.&lt;/p&gt;&#10;</description></item><item><title>Markdown to PDF</title><link>https://blog.schallbert.de/en/md-export-pdf/</link><pubDate>Wed, 09 Jun 2021</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/md-export-pdf/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/markdown-thumb.jpg"&#10; class="post-cover"&#10; alt="markdown to pdv converter extension for VS code"&#10; title="Markdown to PDF" /&gt;&#10;&lt;h3 id="the-tool"&gt;The tool&lt;/h3&gt;&#10;&lt;p&gt;Get a tool that can do the job. I selected the Visual Studio Code extension &lt;a href="https://github.com/yzane/vscode-markdown-pdf" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Yzane&amp;rsquo;s Markdown PDF&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Easy to install, easy to use, good documentation. Only the images&amp;hellip; well&amp;hellip; wouldn&amp;rsquo;t show.&lt;/p&gt;&#10;&lt;h3 id="getting-the-images-converted-too"&gt;Getting the images converted, too&lt;/h3&gt;&#10;&lt;p&gt;The relative links to the images are different between Jekyll&amp;rsquo;s interpretation and the notion from the PDF generator. I had to move the target markdown file into the root folder and add a &lt;code&gt;.&lt;/code&gt; before my &lt;code&gt;/assets&lt;/code&gt; to make it work. Pagination is &amp;hellip;meh&amp;hellip; but I&amp;rsquo;m not willing to put more work in this right now so that&amp;rsquo;s as good as it gets.&lt;/p&gt;&#10;&lt;h3 id="an-issue-with-posts-not-showing-up"&gt;An issue with Posts not showing up?&lt;/h3&gt;&#10;&lt;p&gt;Just upon publishing this sixth post of mine, I see that somehow only page 2 of the post overview is shown (looks like 5 posts per page) and I cannot switch to page 1 containing &amp;ldquo;latest&amp;rdquo; posts: &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2021-06-09_stuckonpage2.jpg" alt="terminal output"&gt;&lt;/figure&gt;. Hm.&lt;/p&gt;&#10;</description></item><item><title>Jekyll: images</title><link>https://blog.schallbert.de/en/jekyll-images/</link><pubDate>Mon, 07 Jun 2021</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/jekyll-images/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/git_mv-thumb.jpg"&#10; class="post-cover"&#10; alt="github move operation for renaming"&#10; title="Jekyll: images" /&gt;&#10;&lt;h3 id="the-trick-to-make-images-show-both-locally-and-on-github-pages"&gt;The trick to make images show both locally and on Github pages&lt;/h3&gt;&#10;&lt;p&gt;When I added some images to my content, all was fine until I pushed to GitHub. Most images were gone and I couldn&amp;rsquo;t figure out why. Some &lt;a href="https://stackoverflow.com/questions/41468951/images-not-displaying-in-github-pages#41469181" target="_blank" rel="noopener noreferrer" class="external-link"&gt;searching&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; revealed that Github is case sensitive and some of my image paths were still mixed case.&lt;/p&gt;&#10;&lt;h3 id="changing-folder-names-in-git"&gt;Changing folder names in git&lt;/h3&gt;&#10;&lt;p&gt;Well, it still didn&amp;rsquo;t work because git didn&amp;rsquo;t recognize my renaming. I first had to create the renamed folder in another subdirectory, use &lt;code&gt;git mv folder/tArGet* target&lt;/code&gt; to get this to a temporary place, delete &lt;code&gt;tArGet&lt;/code&gt; and move &lt;code&gt;target&lt;/code&gt; to the place where &lt;code&gt;tArGet&lt;/code&gt; was before. Only this fixed the issue and now github and my locals are in sync again, luckily.&lt;/p&gt;&#10;</description></item><item><title>This site: Table of Contents</title><link>https://blog.schallbert.de/en/jekyll-toc/</link><pubDate>Sun, 06 Jun 2021</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/jekyll-toc/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/thissite/desktop-thumb.jpg"&#10; class="post-cover"&#10; alt="Schallbert&amp;#39;s Desktop"&#10; title="This site: Table of Contents" /&gt;&#10;&lt;h2 id="table-of-contents"&gt;Table of Contents&lt;/h2&gt;&#10;&lt;p&gt;There is an easy solution for making the page&amp;rsquo;s table of contents stick:&#10;add &lt;code&gt;toc_sticky: true&lt;/code&gt; to the defaults or the specific page&amp;rsquo;s front matter. How I found the keyword? By searching &amp;ldquo;toc&amp;rdquo; in the whole solution and some scrolling through the layout scetches. I modified the original site navigation in the left sidebar and exchanged it with a dummy implementation of a gallery / audio / video contents display.&lt;/p&gt;&#10;&lt;h3 id="toc-on-the-left"&gt;ToC on the left&lt;/h3&gt;&#10;&lt;p&gt;To understand how to create a sticky ToC on the left, please continue reading &lt;a href="https://blog.schallbert.de/en/projects/thissite/#sidebar"&gt;this chapter&lt;/a&gt; of the mySite project.&lt;/p&gt;&#10;&lt;h2 id="authors-profile"&gt;Author&amp;rsquo;s profile&lt;/h2&gt;&#10;&lt;p&gt;I decided I only want my &lt;code&gt;author_profile&lt;/code&gt; to be shown on pages like &lt;a href="https://blog.schallbert.de/en/about/"&gt;about&lt;/a&gt;, &lt;a href="https://blog.schallbert.de/en/legal/"&gt;legal info&lt;/a&gt; and not on the typical content pages. So I wrote &lt;code&gt;author_profile: false&lt;/code&gt; for the pages I didn&amp;rsquo;t want it to show up - with no effect. Why?&#10;I had set it in the &lt;code&gt;config.yml&lt;/code&gt;&amp;rsquo;s type defaults but it should actually go into the frontmatter of the individual files.&lt;/p&gt;&#10;</description></item><item><title>Jekyll `-incremental` option</title><link>https://blog.schallbert.de/en/jekyll-items-update/</link><pubDate>Thu, 03 Jun 2021</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/jekyll-items-update/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/jekyll-thumb.jpg"&#10; class="post-cover"&#10; alt="Jekyll logo"&#10; title="Jekyll `-incremental` option" /&gt;&#10;&lt;h3 id="recent-posts-again"&gt;Recent Posts again&lt;/h3&gt;&#10;&lt;p&gt;I figured out the reason why my last &amp;ldquo;open item&amp;rdquo; post didn&amp;rsquo;t show up on the landing page on my local machine but worked fine on remote. It is because, locally, I&amp;rsquo;m using the command &lt;code&gt;$ bundle exec jekyll serve --incremental&lt;/code&gt; with the &lt;code&gt;--incremental&lt;/code&gt; build option active to speed up rebuilds for quicker test runs. But the issue is here: &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2021-06-03_jekyll_incremental.jpg" alt="terminal output"&gt;&lt;/figure&gt;&#10;On an incremental output, &lt;em&gt;jekyll&lt;/em&gt; doesn&amp;rsquo;t seem to scan the &lt;code&gt;_posts&lt;/code&gt; folder for new entries, as the page they appear on has not been modified directly.&#10;My takeaway is that I&amp;rsquo;ll be using the &lt;code&gt;--incremental&lt;/code&gt; option with care and for small/quick changes only in future.&lt;/p&gt;&#10;&lt;h3 id="the-images"&gt;The images&lt;/h3&gt;&#10;&lt;p&gt;I worked on my &lt;code&gt;assets/images&lt;/code&gt;, they tend to be somewhat &amp;ldquo;big&amp;rdquo; so that page load times might be a reason to worry about. I&amp;rsquo;m using &lt;a href="https://www.getpaint.net/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;paint.net&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to shrink them to a size I think I can afford and save them as &lt;code&gt;.jpg&lt;/code&gt; with compression max&amp;rsquo;ed out and some compromises on image quality. This way, most of my pictures take less than 10% or the original size in kB.&lt;/p&gt;&#10;</description></item><item><title>MinimalMistakes theme - Page design</title><link>https://blog.schallbert.de/en/jekyll-open-items/</link><pubDate>Wed, 02 Jun 2021</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/jekyll-open-items/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/mmistakes-thumb.jpg"&#10; class="post-cover"&#10; alt="MinimalMistakes logo"&#10; title="MinimalMistakes theme - Page design" /&gt;&#10;&lt;h2 id="the-landing-page"&gt;The landing page&lt;/h2&gt;&#10;&lt;p&gt;Yeah, by changing the layout type to &lt;code&gt;single &lt;/code&gt;I was able to get a header image as type &lt;code&gt;overlay&lt;/code&gt; in!&#10;The buttons are displayed within the image now. The image changes with display size and orientation automatically due to the visual designs defined in the &lt;code&gt;_sass&lt;/code&gt; folder.&lt;/p&gt;&#10;&lt;h3 id="landing-page-image"&gt;landing page image&lt;/h3&gt;&#10;&lt;p&gt;Ha, found it! I was freaking out; my landing page now just wouldn&amp;rsquo;t show any image, no matter how hard I tried modifying the &lt;code&gt;home.md&lt;/code&gt; file&amp;hellip; Reason behind was that there was a stock &lt;code&gt;index.html&lt;/code&gt; file that came with the template and that seems to have priority. No I just added my changes in there - works perfectly fine!&#10;By the way, the overlay image&amp;rsquo;s height is automatically selected depending on the overlay text&amp;rsquo;s contents/ height.&lt;/p&gt;&#10;&lt;h3 id="adding-a-page-navigation"&gt;Adding a page navigation&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Easy solution: type &lt;code&gt;toc: true&lt;/code&gt; in the front matter. Unfortunately, eventually you will loose this information as you scroll down because it won&amp;rsquo;t stick to your scrolled view.&lt;/li&gt;&#10;&lt;li&gt;Hard solution: Add page navigation to left sidebar. How? &lt;a href="https://blog.schallbert.de/en/projects/thissite/#navigation"&gt;See here!&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="recent-posts"&gt;Recent posts&lt;/h3&gt;&#10;&lt;p&gt;Hell, what am I writing this down for? It does not seem to appear in the &amp;ldquo;Recent Posts&amp;rdquo; column of the landing page. Oh, that&amp;rsquo;s just because I would have to restart the service because the posts are generated statically &lt;a href="https://blog.schallbert.de/en/jekyll-items-update/"&gt;Right, Jekyll is a &lt;strong&gt;static&lt;/strong&gt; site generator&amp;hellip;&lt;/a&gt;&lt;/p&gt;&#10;</description></item><item><title>ClickEncoder</title><link>https://blog.schallbert.de/en/projects/encoder/</link><pubDate>Mon, 01 Jan 0001</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/projects/encoder/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/encoder/encoder-thumb.jpg"&#10; class="post-cover"&#10; alt="Encoder on Breadboard"&#10; title="ClickEncoder" /&gt;&#10;&lt;h2 id="project-stats"&gt;Project stats&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Difficulty: medium 3/5&lt;/li&gt;&#10;&lt;li&gt;Cost: 0€&lt;/li&gt;&#10;&lt;li&gt;Time: ~6h&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="abstract"&gt;Abstract&lt;/h2&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://raw.githubusercontent.com/isocpp/logos/64ef037049f87ac74875dbe72695e59118b52186/cpp_logo.svg" alt="C&amp;#43;&amp;#43;"&gt;&lt;/figure&gt;Library for the &lt;a href="https://www.arduino.cc/en/Reference/Libraries" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Arduino Framework&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, written in c++.&lt;/p&gt;&#10;&lt;p&gt;It is lightweight because the source code itself is a few hundred lines only, and powerful, because of the improved algorithm uses less calculations while providing a better state interpretation than the original project this is forked from. It is officially released on &lt;a href="https://platformio.org/lib/show/11808/ClickEncoder" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PlatformIO IDE&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and hosted on &lt;a href="https://github.com/Schallbert/encoder" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. It contains examples both for Arduino and PlatformIO IDEs and provides unittests that can be run upon customization.&lt;/p&gt;&#10;&lt;h2 id="motivation"&gt;Motivation&lt;/h2&gt;&#10;&lt;p&gt;I used the original library published by &lt;a href="https://github.com/0xPIT/encoder" target="_blank" rel="noopener noreferrer" class="external-link"&gt;0xPIT&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; at first but that one caused some compiler warnings for me, plus the encoder readings were not flawless and sometimes there was jitter or bouncing I didn&amp;rsquo;t want to happen.&lt;/p&gt;&#10;&lt;h2 id="solution"&gt;Solution&lt;/h2&gt;&#10;&lt;p&gt;As the repo looked unmaintained, I decided to fix these issues by rewriting some code. For my project at that time, I also needed a &amp;ldquo;repeat&amp;rdquo; signal when a button is pressed continuously, so I added this, too. And finally, I separated the encoder algorithm from the button handling and provided separate classes so that, if needed, any combination of button and encoder could be used, each with configurable behavior.&lt;/p&gt;&#10;&lt;h2 id="encoder-algorithm-explained"&gt;Encoder Algorithm explained&lt;/h2&gt;&#10;&lt;h3 id="api"&gt;API&lt;/h3&gt;&#10;&lt;p&gt;ClickEncoder&amp;rsquo;s API is simple and easy to understand: &lt;code&gt;service()&lt;/code&gt; runs the business logic and does the state interpretation. It has to be run regularly to check if the encoder has been moved since its last call, into which direction, how many steps, and has these values saved in an accumulator variable.&#10;&lt;code&gt;getIncrement()&lt;/code&gt; returns the encoder step changes since its last call. &lt;code&gt;getAccumulate()&lt;/code&gt; returns the sum of steps taken since startup. &lt;code&gt;getButton()&lt;/code&gt; returns the current Button state.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;void&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;service&lt;/span&gt;();&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;int16_t&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;getIncrement&lt;/span&gt;();&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;int16_t&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;getAccumulate&lt;/span&gt;();&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Button&lt;span style="color:#f92672"&gt;::&lt;/span&gt;eButtonStates getButton();&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="encoder-state"&gt;Encoder state&lt;/h3&gt;&#10;&lt;p&gt;The encoder uses a &lt;a href="https://en.wikipedia.org/wiki/Gray_code" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gray Code&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to encode the steps. &lt;code&gt;getBitCode()&lt;/code&gt; converts this code transmitted via the Encoder&amp;rsquo;s hardware pins A and B to a &lt;code&gt;0...3&lt;/code&gt; notch interpretation, numbers increasing when turning right, and decreasing when turning left. This is done efficiently without use of &lt;code&gt;if()&lt;/code&gt; or other branching logic.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; Encoder&lt;span style="color:#f92672"&gt;::&lt;/span&gt;getBitCode()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// GrayCode convert&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// !A &amp;amp;&amp;amp; !B --&amp;gt; 0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// !A &amp;amp;&amp;amp; B --&amp;gt; 1&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// A &amp;amp;&amp;amp; B --&amp;gt; 2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// A &amp;amp;&amp;amp; !B --&amp;gt; 3&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; currentEncoderRead &lt;span style="color:#f92672"&gt;=&lt;/span&gt; digitalRead(pinA);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; currentEncoderRead &lt;span style="color:#f92672"&gt;|=&lt;/span&gt; (currentEncoderRead &lt;span style="color:#f92672"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// invert result&amp;#39;s 0th bit if set&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; currentEncoderRead &lt;span style="color:#f92672"&gt;^=&lt;/span&gt; digitalRead(pinB);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; currentEncoderRead;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I used a trick here, so you may have to look at the code snippet above several times. The state of &lt;code&gt;PinA&lt;/code&gt; is written to bit1 of &lt;code&gt;currentEncoderRead&lt;/code&gt; by me shifting it to the left with the &lt;code&gt;&amp;lt;&amp;lt;&lt;/code&gt; operation. &lt;em&gt;But at the same time the state of the 0th bit is preserved by &lt;code&gt;|=&lt;/code&gt;&lt;/em&gt;. If I now bind &lt;code&gt;PinB&lt;/code&gt; simply by logical &lt;code&gt;&amp;amp;=&lt;/code&gt;, then I would violate the Gray code, after which &lt;code&gt;A&amp;amp;&amp;amp;B=2&lt;/code&gt; should be and not &lt;code&gt;3&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;So if &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;B&lt;/code&gt; are both not set, the result remains &lt;code&gt;0&lt;/code&gt;, because &lt;code&gt;00^00=00=0&lt;/code&gt;. If only &lt;code&gt;A&lt;/code&gt; is set, but &lt;code&gt;B&lt;/code&gt; is not, the result is &lt;code&gt;11^00=11=3&lt;/code&gt;. If &lt;code&gt;B&lt;/code&gt; is set, but &lt;code&gt;A&lt;/code&gt; is not, the result is &lt;code&gt;00^01=01=1&lt;/code&gt;. If both are set, finally &lt;code&gt;11^01=10=2&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;For more information about how logical binary operators work, please refer to &lt;a href="https://blog.schallbert.de/en/projects/fireplace/#galois-lfsr-implementation"&gt;my LED fireplace project&lt;/a&gt;&lt;/p&gt;&#10;&lt;h3 id="state-interpretation"&gt;State interpretation&lt;/h3&gt;&#10;&lt;p&gt;After the bit code is read, &lt;code&gt;rawMovement&lt;/code&gt; variable is set that tells us how much the encoder has been turned between two runs of the &lt;code&gt;service()&lt;/code&gt; routine. Here you can see the time criticality because there will be aliasing errors if the encoder changes notches in a time similarly short to the service interval.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;void&lt;/span&gt; Encoder&lt;span style="color:#f92672"&gt;::&lt;/span&gt;handleEncoder()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; encoderRead &lt;span style="color:#f92672"&gt;=&lt;/span&gt; getBitCode();&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// bit0 set = status changed, bit1 set = &amp;#34;overflow 3&amp;#34; where it goes 0-&amp;gt;3 or 3-&amp;gt;0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; rawMovement &lt;span style="color:#f92672"&gt;=&lt;/span&gt; encoderRead &lt;span style="color:#f92672"&gt;-&lt;/span&gt; lastEncoderRead;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; lastEncoderRead &lt;span style="color:#f92672"&gt;=&lt;/span&gt; encoderRead;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// This is the uint-&amp;gt;int magic, converts raw to: -1 counterclockwise, 0 no turn, 1 clockwise&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;int8_t&lt;/span&gt; signedMovement &lt;span style="color:#f92672"&gt;=&lt;/span&gt; ((rawMovement &lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;) &lt;span style="color:#f92672"&gt;-&lt;/span&gt; (rawMovement &lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;));&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; encoderAccumulate &lt;span style="color:#f92672"&gt;+=&lt;/span&gt; signedMovement;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; encoderAccumulate &lt;span style="color:#f92672"&gt;+=&lt;/span&gt; handleAcceleration(signedMovement);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here is another trick: &lt;code&gt;signedMovement&lt;/code&gt; is calculated by converting the movement into a signed integer representing &lt;code&gt;0&lt;/code&gt; as &amp;ldquo;not rotated, &lt;code&gt;-1&lt;/code&gt; as &amp;ldquo;rotated left&amp;rdquo; and &lt;code&gt;1&lt;/code&gt; as &amp;ldquo;rotated right&amp;rdquo;. This exploits the integer overflow behavior, in combination with the interpretation of signedness.&lt;/p&gt;&#10;&lt;p&gt;Finally, the accumulation is performed and the acceleration, if configured, is added to the accumulator depending on how fast the encoder is rotated.&lt;/p&gt;&#10;&lt;p&gt;To program these two methods without branching took me several evenings of grubbling and practically half a pad of squared paper. Frankly, these few lines of code have brought me to the edge of my brain&amp;rsquo;s capacity. I am just not a &amp;ldquo;real&amp;rdquo; programmer 😅&lt;/p&gt;&#10;&lt;h3 id="the-getaccumulate-method"&gt;The getAccumulate() method&lt;/h3&gt;&#10;&lt;p&gt;It&amp;rsquo;s really simple, it just returns the internal accumulated value accounted to the encoder&amp;rsquo;s configuration about how many notches make a step.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;int16_t&lt;/span&gt; Encoder&lt;span style="color:#f92672"&gt;::&lt;/span&gt;getAccumulate()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; (encoderAccumulate &lt;span style="color:#f92672"&gt;/&lt;/span&gt; stepsPerNotch);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="the-getincrement-method"&gt;The getIncrement() method&lt;/h3&gt;&#10;&lt;p&gt;Returns how much the encoder&amp;rsquo;s values have changed since its last call.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;int16_t&lt;/span&gt; Encoder&lt;span style="color:#f92672"&gt;::&lt;/span&gt;getIncrement()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;int16_t&lt;/span&gt; accu &lt;span style="color:#f92672"&gt;=&lt;/span&gt; getAccumulate();&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;int16_t&lt;/span&gt; encoderIncrements &lt;span style="color:#f92672"&gt;=&lt;/span&gt; accu &lt;span style="color:#f92672"&gt;-&lt;/span&gt; lastEncoderAccumulate;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; lastEncoderAccumulate &lt;span style="color:#f92672"&gt;=&lt;/span&gt; accu;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; (encoderIncrements);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="button-state-calculation-explained"&gt;Button state calculation explained&lt;/h2&gt;&#10;&lt;h3 id="the-handlebutton-method"&gt;The handleButton() method&lt;/h3&gt;&#10;&lt;p&gt;All button states derive from the two basic states: pressed or not pressed. This is reflected in the logic as well:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;void&lt;/span&gt; Button&lt;span style="color:#f92672"&gt;::&lt;/span&gt;handleButton()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (lastGetButtonCount &lt;span style="color:#f92672"&gt;&amp;lt;&lt;/span&gt; ENC_BUTTONINTERVAL)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; lastGetButtonCount &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (digitalRead(pinBTN) &lt;span style="color:#f92672"&gt;==&lt;/span&gt; pinActiveState)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; handleButtonPressed();&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; handleButtonReleased();&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; (doubleClickTicks &lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;--&lt;/span&gt;doubleClickTicks;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The button can be configured to be read less often than the encoder. If this is the case, this method will be left without any action.&lt;/p&gt;&#10;&lt;h3 id="button-pressed"&gt;Button pressed:&lt;/h3&gt;&#10;&lt;p&gt;&lt;code&gt;handleButtonPressed()&lt;/code&gt; can either return &lt;code&gt;Closed&lt;/code&gt;, &lt;code&gt;Held&lt;/code&gt;, or &lt;code&gt;LongPressRepeat&lt;/code&gt; depending on how the button is configured and how long it has been pressed already.&lt;/p&gt;&#10;&lt;h3 id="button-not-pressed"&gt;Button not pressed:&lt;/h3&gt;&#10;&lt;p&gt;&lt;code&gt;handleButtonReleased()&lt;/code&gt; can either return &lt;code&gt;Clicked&lt;/code&gt;, &lt;code&gt;Released&lt;/code&gt;, or &lt;code&gt;Doubleclicked&lt;/code&gt; depending on button configuration and click count within a certain time.&lt;/p&gt;&#10;&lt;h3 id="library-configuration"&gt;Library configuration&lt;/h3&gt;&#10;&lt;p&gt;The encoder/button detection behavior can be modified in the &lt;code&gt;encoder.h&lt;/code&gt; file using the constants shown below. The values have been tweaked already and feel natural to me, at least. They should be modified if you choose to use a different service interval than 1ms because those values all take this interval as reference.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// ----------------------------------------------------------------------------&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// Acceleration configuration (for 1ms calls to ::service())&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;//&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;constexpr&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; ENC_ACCEL_START &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;150&lt;/span&gt;; &lt;span style="color:#75715e"&gt;// The smaller this value, the quicker you must turn to activate acceleration.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;constexpr&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; ENC_ACCEL_SLOPE &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;75&lt;/span&gt;; &lt;span style="color:#75715e"&gt;// the smaller this value, the stronger the acceleration will manipulate values.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// Button configuration (values for 1ms timer service calls)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;//&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;constexpr&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; ENC_BUTTONINTERVAL &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;; &lt;span style="color:#75715e"&gt;// check button every x ms, also debouce time&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;constexpr&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint16_t&lt;/span&gt; ENC_DOUBLECLICKTIME &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;400&lt;/span&gt;; &lt;span style="color:#75715e"&gt;// second click within x ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;constexpr&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint16_t&lt;/span&gt; ENC_LONGPRESSREPEATINTERVAL &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;200&lt;/span&gt;; &lt;span style="color:#75715e"&gt;// reports repeating-held every x ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;constexpr&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;uint16_t&lt;/span&gt; ENC_HOLDTIME &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1200&lt;/span&gt;; &lt;span style="color:#75715e"&gt;// report held button after x ms&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// ----------------------------------------------------------------------------&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="use-it-in-your-project"&gt;Use it in your project!&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The library is open source software, using the MIT license.&lt;/li&gt;&#10;&lt;li&gt;PlatformIO user? Easy, just search it in the &amp;lsquo;Libraries&amp;rsquo; view, Tags are Arduino, Encoder, Schallbert. It&amp;rsquo;s then just one more click to add it to your solution&lt;/li&gt;&#10;&lt;li&gt;Alternatively, just clone or fork the &lt;a href="https://github.com/Schallbert/encoder" target="_blank" rel="noopener noreferrer" class="external-link"&gt;repository&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and use the header/cpp file as you deem fit.&lt;/li&gt;&#10;&lt;li&gt;Contribute? Maybe you have a more efficient state calculation of the button? I&amp;rsquo;d be happy to review your pull request :)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Github Pages: Moving out</title><link>https://blog.schallbert.de/en/projects/move-blog-to-own-server/</link><pubDate>Mon, 01 Jan 0001</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/projects/move-blog-to-own-server/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/movetoownserver/giteawebdashboard-thumb.jpg"&#10; class="post-cover"&#10; alt="Image: Gitea Dashboard, served on my machine, proxied by caddy"&#10; title="Github Pages: Moving out" /&gt;&#10;&lt;h2 id="project-stats"&gt;Project stats&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Difficulty: medium 3/5&lt;/li&gt;&#10;&lt;li&gt;Cost: 5-50€/Month&lt;/li&gt;&#10;&lt;li&gt;Time: ~10h&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="motivation"&gt;Motivation&lt;/h2&gt;&#10;&lt;p&gt;I decided to move &lt;a href="https://blog.schallbert.de/en/projects/thissite/"&gt;my Website&lt;/a&gt; from &lt;a href="https://pages.github.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github-Pages&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to my own server. I have two reasons for that: First, I want the server to be situated in Germany (data protection) and second, I wanted a simple way of providing my page in two languages.&lt;/p&gt;&#10;&lt;p&gt;In addition, I might be able to realize additional wishes: Setup a miniature file server and add a Website for my small side-hustle. But those will be handled in a future post.&lt;/p&gt;&#10;&lt;h2 id="overview"&gt;Overview&lt;/h2&gt;&#10;&lt;p&gt;In this project, I&amp;rsquo;ll rent a virtual server, configure access via &lt;code&gt;SSH&lt;/code&gt; and install a container manager called &lt;a href="https://www.docker.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Docker&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;In one of the containers, I want an instance of &lt;a href="https://docs.gitea.com/next/installation/install-with-docker#startup" target="_blank" rel="noopener noreferrer" class="external-link"&gt;gitea&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to provide version controls for website, and have a CI/CD&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt; pipeline setup that automatically integrates and deploys my site.&lt;/p&gt;&#10;&lt;p&gt;Not unlike Github, &lt;a href="https://docs.gitea.com/next/usage/actions/overview" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Gitea Actions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; allows me to deploy the site built with &lt;code&gt;Jekyll&lt;/code&gt; via &lt;a href="https://docs.gitea.com/next/usage/actions/quickstart#use-actions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Workflow files&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; on a &lt;code&gt;caddy&lt;/code&gt;-Server instance.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://caddyserver.com/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;caddy&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is not just a webserver, but also provides a &lt;a href="https://en.wikipedia.org/wiki/Reverse_proxy" target="_blank" rel="noopener noreferrer" class="external-link"&gt;reverse proxy&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; which does Port mapping and address resolution. In addition, &lt;code&gt;caddy&lt;/code&gt; manages my &lt;code&gt;https&lt;/code&gt; certificates easily per &lt;a href="https://letsencrypt.org/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Let&amp;rsquo;s Encrypt&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;As caddy will run in an own docker container, I have to generate and configure virtual networks so the containers can &amp;ldquo;talk&amp;rdquo; to each other.&lt;/p&gt;&#10;&lt;p&gt;Let&amp;rsquo;s get going - step by step.&lt;/p&gt;&#10;&lt;h2 id="configuring-my-server"&gt;Configuring my server&lt;/h2&gt;&#10;&lt;p&gt;The upcoming sections describe the individual steps of configuring a cloud-server for deployment of a static site, including version control and CI.&lt;/p&gt;&#10;&lt;h3 id="configure-ssh-for-the-server"&gt;configure SSH for the server&lt;/h3&gt;&#10;&lt;p&gt;Right after ordering the &amp;ldquo;Cloud-server&amp;rdquo;, I enabled SSH access and deactivated its password login.&#10;To connect from my machine, I check whether the ssh-agent is already up and running per &lt;code&gt;eval ssh-agent&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Then, I create a new key pair via&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;ssh-keygen -t ed25519 -C &amp;quot;your_email@example.com&amp;quot; -f &amp;quot;~/.ssh/my-cloud-server&amp;quot;&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;This line creates a key pair with algorithm (&lt;code&gt;-t&lt;/code&gt;) &lt;code&gt;ed25519&lt;/code&gt;, taking a mail address as comment (&lt;code&gt;-C&lt;/code&gt;) that is then saved with filename (&lt;code&gt;-f&lt;/code&gt;) &lt;code&gt;my-cloud-server&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The public key file &lt;code&gt;my-cloud-server.pub&lt;/code&gt; is then uploaded on the web portal of my server using the &amp;ldquo;security&amp;rdquo; tab. The resulting fingerprint is then verified with:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;ssh-keygen -lf &amp;lt;fingerprint_from_server&amp;gt; &amp;quot;~/.ssh/mein-cloud-server&amp;quot;&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;Finally, I modified the &lt;code&gt;.ssh/config&lt;/code&gt; file so that, to connect, I just need to enter &lt;code&gt;ssh server&lt;/code&gt; into my console instead of the server&amp;rsquo;s IP-Address - pretty Lazy 😀&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#.ssh/config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;Host server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;User root&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Hostname ip_address_of_server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;PreferredAuthentications publickey&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;IdentityFile ~/.ssh/my-cloud-server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now when I enter &lt;code&gt;ssh server&lt;/code&gt; the first time, I get a prompt about a potential connection to a yet unknown host with fingerprint &lt;code&gt;xyz&lt;/code&gt; and get asked whether I&amp;rsquo;d want that to happen. I compare this fingerprint to the server&amp;rsquo;s (use provider&amp;rsquo;s Website to verify). This way I can make sure that I&amp;rsquo;m trying to connect to the correct party. Once I give my &lt;code&gt;yes&lt;/code&gt;, the fingerprint is added to the &lt;code&gt;known_hosts&lt;/code&gt; file and future connections will be made directly and without warning.&lt;/p&gt;&#10;&lt;h2 id="install-docker-"&gt;Install Docker 🐳&lt;/h2&gt;&#10;&lt;p&gt;My server runs a standard Ubuntu that supports &lt;code&gt;apt&lt;/code&gt; commands out of the box. So installing Docker per &lt;a href="https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Manual&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; is really easy: Connect to the server per SSH, type &lt;code&gt;apt install docker&lt;/code&gt;, done. I&amp;rsquo;ll be using &lt;code&gt;docker-compose&lt;/code&gt; which also auto-installs. &lt;code&gt;Compose&lt;/code&gt; interprets configuration files that define the target container and make it really easy to get them up and running.&lt;/p&gt;&#10;&lt;h2 id="caddy"&gt;Install caddy 🛒&lt;/h2&gt;&#10;&lt;p&gt;First, I create a folder in my &lt;code&gt;/opt&lt;/code&gt; directory via &lt;code&gt;mkdir caddy2&lt;/code&gt;. To have caddy run in an own container, I change into that directory and create the following &lt;code&gt;docker-compose.yml&lt;/code&gt; file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#caddy2/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;version&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#39;3&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;services&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;caddy&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;caddy:latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container_name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;caddy&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;ports&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#e6db74"&gt;&amp;#34;80:80&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#e6db74"&gt;&amp;#34;443:443&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;environment&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;ACME_AGREE=true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;restart&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;unless-stopped&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./Caddyfile:/etc/caddy/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./caddy_data:/data&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./caddy_config:/config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./www:/www&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;networks&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;caddy-proxy&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;networks&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;caddy-proxy&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;external&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This file tells docker to use latest &lt;code&gt;caddy&lt;/code&gt; release. The container is named &lt;code&gt;caddy&lt;/code&gt; and uses ports &lt;code&gt;80&lt;/code&gt; (http) and &lt;code&gt;443&lt;/code&gt; (https). Certificates are requested via &lt;a href="https://www.howtogeek.com/devops/how-to-request-a-letsencrypt-certificate-using-acme/#installing-acme-sh" target="_blank" rel="noopener noreferrer" class="external-link"&gt;&lt;code&gt;ACME_AGREE&lt;/code&gt;&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and the container is able to access required files (Caddyfile, data, config) on my host machine via a volume. It will also be able to access files in &lt;code&gt;/www&lt;/code&gt; folder that, in the future, will host my blog&amp;rsquo;s site data. The container is part of the &lt;code&gt;caddy-proxy&lt;/code&gt; network that has been provided externally.&lt;/p&gt;&#10;&lt;p&gt;So, to be able to run this, I&amp;rsquo;ll first have to create that network:&lt;code&gt;docker network create caddy-proxy&lt;/code&gt;. Then a quick check per &lt;code&gt;docker network ls&lt;/code&gt; shows that the network has been added:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;NETWORK ID NAME DRIVER SCOPE&#10;&amp;lt;redacted&amp;gt; bridge bridge local&#10;&amp;lt;redacted&amp;gt; caddy2_default bridge local&#10;&amp;lt;redacted&amp;gt; caddy-proxy bridge local&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now I create a &lt;code&gt;Caddyfile&lt;/code&gt; configuration with following content:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# caddy2/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;blog.schallbert.de {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;root * /www/blog&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;encode gzip&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;file_server&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Caddy is told that the content of &lt;code&gt;/www/blog&lt;/code&gt; shall be provided as a (static) file server, supporting &lt;code&gt;gzip&lt;/code&gt;-compression, available under &lt;a href="https://blog.schallbert.de/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;blog.schallbert.de&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="create-a-test-website-"&gt;Create a test website 🧪&lt;/h3&gt;&#10;&lt;p&gt;Before we start with the page design, we should be able to have the address resolved&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;. I log into my DNS-manager and bind a static IP address to my subdomain:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;TYPE NAME VALUE TTL&#10;A blog.schallbert.de &amp;lt;redacted&amp;gt; 86400&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;My server shall be able to show something. So let&amp;rsquo;s create a minimalistic test page:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;mkdir ~/caddy2/www/blog&#10;cd ~/caddy2/www/blog&#10;nano index.html&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Hmm, Just a little something:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-html" data-lang="html"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;&amp;lt;!-- blog/index.html --&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;h1&lt;/span&gt;&amp;gt;ALL YOUR BASE ARE BELONG TO US&amp;lt;/&lt;span style="color:#f92672"&gt;h1&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Save it, done.&lt;/p&gt;&#10;&lt;p&gt;This is how caddy&amp;rsquo;s root directory looks like now:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;*caddy_config* *caddy_data* Caddyfile docker-compose.yml *www*&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We&amp;rsquo;re ready now to create the container:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;docker compose up -d&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The Option &lt;code&gt;-d&lt;/code&gt; means &amp;ldquo;detach&amp;rdquo;, so the container is started and detached from the console, unblocking it so we can reuse it for more prompts.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;[+] Running 1/1&#10; ✔ Container caddy Started&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This looks good. Now I navigate to the page:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/movetoownserver/address.jpg" alt="Image: caddy is displaying the test page correctly"&gt;&lt;/figure&gt;&#10;&lt;h2 id="install-gitea-"&gt;Install Gitea 🍵&lt;/h2&gt;&#10;&lt;p&gt;I need Gitea for two purposes: I want version control also for private repositories and I want to host my website with Jekyll with full CI/CD automation.&lt;/p&gt;&#10;&lt;p&gt;Like caddy, it starts with a folder:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;cd /opt&#10;mkdir gitea&#10;cd gitea&#10;nano docker-compose.yml&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I compiled Gitea&amp;rsquo;s &lt;code&gt;docker-compose.yml&lt;/code&gt; out of dfferent &lt;a href="https://docs.gitea.com/installation/install-with-docker" target="_blank" rel="noopener noreferrer" class="external-link"&gt;example files&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Here it is:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;version&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;3&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;networks&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;gitea&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;external&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;caddy-proxy&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;external&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;caddy-proxy&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;services&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;gitea&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/gitea:latest&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;container_name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;restart&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;always&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;environment&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;USER_UID=1000&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;USER_GID=1000&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;SSH_DOMAIN=git.schallbert.de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_HOSTNAME=git.schallbert.de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;networks&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;caddy-proxy&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./gitea:/data&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./log:/app/gitea/log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;ports&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#e6db74"&gt;&amp;#34;127.0.0.1:3000:3000&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#e6db74"&gt;&amp;#34;222:22&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;runner&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/act_runner:nightly&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;environment&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;CONFIG_FILE=/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_INSTANCE_URL=https://git.schallbert.de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_RUNNER_NAME=ichlaufe&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_RUNNER_REGISTRATION_TOKEN=&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;volumes&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./runner/config.yml:/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;./runner/data:/data&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is what it does: &lt;code&gt;gitea&lt;/code&gt; is member of docker networks &lt;code&gt;gitea&lt;/code&gt; and &lt;code&gt;caddy-proxy&lt;/code&gt;. The latter has been added so I can reach gitea from &amp;ldquo;the internet&amp;rdquo;. The container running the service is named &lt;code&gt;gitea&lt;/code&gt; and is automatically restarted when down. User-ID is set to &lt;code&gt;1000&lt;/code&gt; so it won&amp;rsquo;t interfere with admins or other special users. Per SSH and http, gitea can be reached under &lt;code&gt;git.schallbert.de&lt;/code&gt; on ports &lt;code&gt;22&lt;/code&gt; and &lt;code&gt;3000&lt;/code&gt; - the latter is localhost only.&lt;/p&gt;&#10;&lt;p&gt;Gitea has access to the host&amp;rsquo;s time zone and local time, it saves its logs under &lt;code&gt;/log&lt;/code&gt;and files under &lt;code&gt;/data&lt;/code&gt;. Gitea gets an &lt;code&gt;action runner&lt;/code&gt; that runs on the gitea instance. Its name is &lt;code&gt;ichlaufe&lt;/code&gt; and the config is available under &lt;code&gt;config.yml&lt;/code&gt; as a volume. The runner can access Docker daemon&amp;rsquo;s &lt;a href="https://stackoverflow.com/questions/35110146/what-is-the-purpose-of-the-file-docker-sock" target="_blank" rel="noopener noreferrer" class="external-link"&gt;standard-socket&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to run actions, e.g. install &lt;code&gt;ubuntu-latest&lt;/code&gt; in an own container to run &lt;code&gt;Jekyll&lt;/code&gt; builds. I took the basic config for the runner from &lt;a href="https://gitea.com/gitea/act_runner/src/branch/main/examples/docker-compose" target="_blank" rel="noopener noreferrer" class="external-link"&gt;the official repository&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Gitea will only accept runners that are registered via &lt;code&gt;GITEA_RUNNER_REGISTRATION_TOKEN&lt;/code&gt;. We&amp;rsquo;ll do this at a later point in time.&lt;/p&gt;&#10;&lt;p&gt;Now we start Gitea for the first time and look at the console print:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;docker compose up&#10;[+] Running 4/2&#10; ✔ Network gitea_default Created 0.0s &#10; ✔ Network gitea_gitea Created 0.1s &#10; ✔ Container gitea Created 0.1s &#10; ✔ Container gitea-runner-1 Created 0.0s &#10;Attaching to gitea, gitea-runner-1&#10;gitea | Server listening on :: port 22.&#10;gitea | Server listening on 0.0.0.0 port 22.&#10;gitea | 2023/11/18 17:19:52 cmd/web.go:242:runWeb() [I] Starting Gitea on PID: 15&#10;gitea | 2023/11/18 17:19:52 cmd/web.go:111:showWebStartupMessage() [I] Gitea version: 1.21.0 built with GNU Make 4.4.1, go1.21.4 : bindata, timetzdata, sqlite, sqlite_unlock_notify&#10;gitea | 2023/11/18 17:19:52 cmd/web.go:112:showWebStartupMessage() [I] * RunMode: prod&#10;gitea | 2023/11/18 17:19:52 cmd/web.go:113:showWebStartupMessage() [I] * AppPath: /usr/local/bin/gitea&#10;gitea | 2023/11/18 17:19:52 cmd/web.go:114:showWebStartupMessage() [I] * WorkPath: /data/gitea&#10;gitea | 2023/11/18 17:19:52 cmd/web.go:115:showWebStartupMessage() [I] * CustomPath: /data/gitea&#10;gitea | 2023/11/18 17:19:52 cmd/web.go:116:showWebStartupMessage() [I] * ConfigFile: /data/gitea/conf/app.ini&#10;gitea | 2023/11/18 17:19:52 cmd/web.go:117:showWebStartupMessage() [I] Prepare to run web server&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="web-access"&gt;Configure web access&lt;/h3&gt;&#10;&lt;p&gt;Now I have another subdomain added to my DNS manager:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;TYPE NAME VALUE TTL&#10;A git.schallbert.de &amp;lt;redacted&amp;gt; 86400&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I also have to modify &lt;code&gt;Caddyfile&lt;/code&gt; so that web requests are forwarded accordingly (reverse proxy). To do this, I navigate back to the &lt;code&gt;caddy2&lt;/code&gt; folder, type &lt;code&gt;nano Caddyfile&lt;/code&gt; and add the following section:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# caddy2/Caddyfile&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;git.schallbert.de {&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;reverse_proxy * http://gitea:3000&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now requests to &lt;code&gt;git.schallbert.de&lt;/code&gt; are forwarded to the (local) port &lt;code&gt;3000&lt;/code&gt; of the Gitea-container that I opened in the docker compose file before.&lt;/p&gt;&#10;&lt;h3 id="configuring-gitea-per-web-page"&gt;Configuring Gitea per web page&lt;/h3&gt;&#10;&lt;p&gt;When I use my browser to navigate to the above page, I get a login screen - identical to &lt;a href="https://gitea.com/user/login" target="_blank" rel="noopener noreferrer" class="external-link"&gt;this demo&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Now I create a nice password, click &lt;code&gt;Register&lt;/code&gt; and then configure Gitea using my browser. I only do a rough config because I&amp;rsquo;ll have to modify it again later to properly add the runner.&lt;/p&gt;&#10;&lt;p&gt;When done, I create a new SSH key pair as described in the section &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/#configure-ssh-for-the-server"&gt;Configure SSH&lt;/a&gt; above with filename &lt;code&gt;-f ~/.ssh/gitea&lt;/code&gt;. It is uploaded and verified under &lt;code&gt;Settings-&amp;gt;SSH/GPG Keys&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Now I can create another entry on my local machine&amp;rsquo;s &lt;code&gt;.ssh/config&lt;/code&gt; file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# .ssh/config&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;Host gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;User schallbert&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Hostname git.schallbert.de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;PreferredAuthentications publickey&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Identityfile ~/.ssh/schallbert_gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Let&amp;rsquo;s check if this works:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;ssh gitea&#10;Host &amp;#39;git.schallbert.de&amp;#39; is known and matches the ED25519 host key.&#10;debug1: Found key in /home/schallbert/.ssh/known_hosts&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Perfect.&lt;/p&gt;&#10;&lt;h3 id="complete-gitea-config-via-appini"&gt;Complete Gitea config via &lt;code&gt;app.ini&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;As the configuration is not complete, I shut down the container again:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;docker compose down&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and then navigate to &lt;code&gt;/gitea/gitea/conf&lt;/code&gt; and modify the &lt;code&gt;app.ini&lt;/code&gt; just a little:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# conf/app.ini&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;APP_NAME&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;Gitea: Git with a cup of tea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;RUN_MODE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;prod&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;RUN_USER&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;git&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;WORK_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[repository]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ROOT&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/git/repositories&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DEFAULT_BRANCH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;main&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[repository.local]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;LOCAL_COPY_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/tmp/local-repo&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[repository.upload]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;TEMP_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/uploads&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[server]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;APP_DATA_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DOMAIN&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;git.schallbert.de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;SSH_DOMAIN&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;git.schallbert.de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;HTTP_PORT&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;3000&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ROOT_URL&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;https://git.schallbert.de/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DISABLE_SSH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;SSH_PORT&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;222&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;SSH_LISTEN_PORT&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;22&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;LFS_START_SERVER&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;LFS_JWT_SECRET&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;OFFLINE_MODE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[database]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/gitea.db&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DB_TYPE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;sqlite3&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;HOST&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;localhost:3306&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;NAME&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;gitea&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;USER&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;root&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;PASSWD&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;LOG_SQL&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;SCHEMA&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;SSL_MODE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;disable&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[indexer]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ISSUE_INDEXER_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/indexers/issues.bleve&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[session]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;PROVIDER_CONFIG&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/sessions&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;PROVIDER&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[picture]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;AVATAR_UPLOAD_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/avatars&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REPOSITORY_AVATAR_UPLOAD_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/repo-avatars&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[attachment]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/attachments&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[log]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;MODE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;LEVEL&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;info&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ROOT_PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/gitea/log&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[security]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;INSTALL_LOCK&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;SECRET_KEY&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REVERSE_PROXY_LIMIT&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REVERSE_PROXY_TRUSTED_PROXIES&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;*&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;INTERNAL_TOKEN&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;PASSWORD_HASH_ALGO&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;pbkdf2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[service]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DISABLE_REGISTRATION&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REQUIRE_SIGNIN_VIEW&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;REGISTER_EMAIL_CONFIRM&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLE_NOTIFY_MAIL&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ALLOW_ONLY_EXTERNAL_REGISTRATION&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLE_CAPTCHA&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DEFAULT_KEEP_EMAIL_PRIVATE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DEFAULT_ALLOW_CREATE_ORGANIZATION&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DEFAULT_ENABLE_TIMETRACKING&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;NO_REPLY_ADDRESS&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;noreply.localhost&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[lfs]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;PATH&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;/data/git/lfs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[mailer]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLED&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[openid]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLE_OPENID_SIGNIN&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLE_OPENID_SIGNUP&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[cron.update_checker]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLED&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[repository.pull-request]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DEFAULT_MERGE_STYLE&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;merge&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[repository.signing]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;DEFAULT_TRUST_MODEL&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;committer&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[oauth2]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;JWT_SECRET&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;[actions]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ENABLED&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here it is important to make sure that &lt;code&gt;ROOT_URL&lt;/code&gt; and port configuration match the one specified in &lt;code&gt;docker-compose.yml&lt;/code&gt;. I configure its database (sqlite3) and make it available to Gitea. Plus, I deactivate Giteas ability to register new users &lt;code&gt;DISABLE_REGISTRATION = true&lt;/code&gt; and the option to sign up/in via openID &lt;code&gt;ENABLE_OPENID_SIGNIN = false ENABLE_OPENID_SIGNUP = false&lt;/code&gt;. Finally, I enable Gitea Actions &lt;code&gt;ENABLED = true&lt;/code&gt; and save the file.&lt;/p&gt;&#10;&lt;p&gt;Now I can restart the container:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;docker compose up&#10;[+] Running 4/4&#10; ✔ Network gitea_default Created 0.1s &#10; ✔ Network gitea_gitea Created 0.0s &#10; ✔ Container gitea Started 0.0s &#10; x Container gitea-runner-1 Error 0.1s &#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Very well, Gitea is up and running. In a last step, I configure the Action Runner.&lt;/p&gt;&#10;&lt;h2 id="setup-action-runner-"&gt;Setup Action Runner 🏃&lt;/h2&gt;&#10;&lt;p&gt;I use the official &lt;a href="https://docs.gitea.com/usage/actions/act-runner" target="_blank" rel="noopener noreferrer" class="external-link"&gt;documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; provided by Gitea for inspiration.&lt;/p&gt;&#10;&lt;p&gt;I login to Giteas Web surface and see that activating Actions in the &lt;code&gt;app.ini&lt;/code&gt; added another menu: &lt;code&gt;Settings-&amp;gt;Actions&lt;/code&gt;.&#10;Here I click &lt;code&gt;Create new Runner&lt;/code&gt; und copy its &lt;code&gt;REGISTRATION TOKEN&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Then, I re-open Gitea&amp;rsquo;s &lt;code&gt;docker-compose.yml&lt;/code&gt; and add the following lines &lt;a href="https://docs.gitea.com/usage/actions/act-runner#set-up-the-runner-using-docker-compose" target="_blank" rel="noopener noreferrer" class="external-link"&gt;as recommended in the doc page&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# gitea/docker-compose.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;runner&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;gitea/act_runner:nightly&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;environment&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;CONFIG_FILE=/config.yml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_INSTANCE_URL=https://git.schallbert.de&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_RUNNER_NAME=ichlaufe&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;GITEA_RUNNER_REGISTRATION_TOKEN= &amp;lt;redacted&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I restart the container with &lt;code&gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt; and get a &amp;ldquo;started&amp;rdquo; message for the runner in the logs:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;✔ Network gitea_default Created 0.1s &#10;✔ Network gitea_gitea Created 0.0s &#10;✔ Container gitea Started 0.0s &#10;✔ Container gitea-runner-1 Started 0.1s &#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Great, let&amp;rsquo;s have a look at the web page:&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/movetoownserver/actionrunner.jpg" alt="Image: Gitea action runner is up and - running."&gt;&lt;/figure&gt;&lt;/p&gt;&#10;&lt;p&gt;Now we have to know if the runner can do something for us.&lt;/p&gt;&#10;&lt;h3 id="runner-functional-test"&gt;Runner functional test&lt;/h3&gt;&#10;&lt;p&gt;Once again, I have a look at the &lt;a href="https://blog.gitea.com/hacking-on-gitea-actions/#use-actions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Runner&amp;rsquo;s documentation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. To check, I create a new Repository on the web surface, name it &lt;code&gt;runner-test&lt;/code&gt;, and add a file to &lt;code&gt;/.gitea/workflows/01-test.yml&lt;/code&gt; containing the recommended runner check.&lt;/p&gt;&#10;&lt;p&gt;Commit, push, and - nothing.&lt;/p&gt;&#10;&lt;p&gt;Oh, the workflow starts &lt;code&gt;on: [push]&lt;/code&gt;. This cannot work when it has just been uploaded itself.&lt;/p&gt;&#10;&lt;p&gt;Well, let&amp;rsquo;s &lt;code&gt;touch&lt;/code&gt; another random file in the repository. Commit, push, result:&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/movetoownserver/actionsuccess.jpg" alt="Image: Gitea action runner demo passes integration"&gt;&lt;/figure&gt;&lt;/p&gt;&#10;&lt;p&gt;Fantastic! 🥳&lt;/p&gt;&#10;&lt;h2 id="interim-conclusion"&gt;Interim conclusion&lt;/h2&gt;&#10;&lt;p&gt;In this article, I pretended that the road to a working server setup was straight and without obstacles. Of course, that is not entirely true. It was really simple to run images in Docker, but to have them communicate turned out less straight-forward. I also had quite some problems with &lt;code&gt;Caddyfile&lt;/code&gt; as it is hard to debug and I couldn&amp;rsquo;t see what I was doing wrong.&lt;/p&gt;&#10;&lt;p&gt;For instance, I was able to ping the website on my host but couldn&amp;rsquo;t reach it from the outside. So I knew it is the caddy config to modify.&lt;/p&gt;&#10;&lt;p&gt;Root cause was missing the Caddyfile in the Docker volume list and forgetting a leading &lt;code&gt;/&lt;/code&gt; in the target volume folder.&lt;/p&gt;&#10;&lt;p&gt;On the other hand, configuring Gitea was much easier than I thought. Even complex process of registering the runner and its routing through Docker worked at first try.&lt;/p&gt;&#10;&lt;p&gt;Now I have a quick and lightweight server that will be able to host my blog with ease.&lt;/p&gt;&#10;&lt;p&gt;Next steps - creating a build process for my site - is tried &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-native-jekyll/"&gt;in this post&lt;/a&gt;, but then replaced with native Jekyll &lt;a href="https://blog.schallbert.de/en/gitea-action-runner-jekyll-dockerimage/"&gt;over here&lt;/a&gt;.&lt;/p&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Update: Server architecture&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2024-01-27T00:00:00Z"&gt;&#10; 2024-01-27&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; I have now added several peripheral functions to the server. I have taken care of &lt;a href="https://blog.schallbert.de/en/server-protection/"&gt;security and backups&lt;/a&gt; as well as &lt;a href="https://blog.schallbert.de/en/server-auto-upgrade/"&gt;automatic updates&lt;/a&gt;, whereby the encapsulation by Docker has cost me a lot of additional time and some headache. Even if not all aspects are yet fully covered (notifications, for example, are still an open issue), I am getting noticeably closer to my goal of creating a largely autonomous system.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;p&gt;Ultimately, I want to get back to creating content. And not spend any more time than necessary on all the other stuff.&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;CI/CD &amp;ldquo;&lt;a href="https://en.wikipedia.org/wiki/Continuous_integration" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Continuous Integration&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; / &lt;a href="https://en.wikipedia.org/wiki/Continuous_deployment" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Continuous deployment&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;rdquo; cares for automatic build, verification, and deployment of my web page.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:2"&gt;&#10;&lt;p&gt;DNS = &lt;a href="https://en.wikipedia.org/wiki/Domain_Name_System" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Domain Name System&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, a distributed hierarchical system for name resolution of websites. It exists because it&amp;rsquo;s much less straight forward to enter an IPV6 address like &lt;code&gt;2a02:ec80:300:ed1a:0:0:0:1&lt;/code&gt; into your web browser than &lt;code&gt;en.wikipedia.org&lt;/code&gt;.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>GoogleTest Demo</title><link>https://blog.schallbert.de/en/projects/platformio_gtestgmock/</link><pubDate>Mon, 01 Jan 0001</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/projects/platformio_gtestgmock/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/platformio_gtestgmock/build_test_success-thumb.jpg"&#10; class="post-cover"&#10; alt="Googletest&amp;#39;s successful test results on the example program"&#10; title="GoogleTest Demo" /&gt;&#10;&lt;h2 id="project-stats"&gt;Project stats&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Difficulty: medium 3/5&lt;/li&gt;&#10;&lt;li&gt;Cost: 0€&lt;/li&gt;&#10;&lt;li&gt;Time: ~2h&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="motivation"&gt;Motivation&lt;/h2&gt;&#10;&lt;p&gt;It is nice to have a powerful unit test framework at hand when developing more complex projects, possibly saving a major amount of time debugging (especially on-Target debugging in the embedded world can be really time-consuming), improving code quality and possibly boosting insights to APIs and architecture for others.&lt;/p&gt;&#10;&lt;h2 id="the-embedded-world"&gt;The embedded world&lt;/h2&gt;&#10;&lt;p&gt;Traditionally, microcontrollers were limited on the memory side. In the past, they neither had much RAM nor PROGMEM so the code I wrote was mostly in lean &lt;code&gt;C&lt;/code&gt;, and configuration took place in lists of &lt;code&gt;#define&lt;/code&gt; that wouldn&amp;rsquo;t weigh much because the preprocessor would do the work; not the µC at runtime.&#10;My unit tests were alyways bound to the hardware they were running on - take &lt;a href="https://github.com/ThrowTheSwitch/Unity" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Unity&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; as a well-known example - but now I wanted to try something new:&#10;Write Object-oriented style code, and get the business logic tested independently of the hardware. I selected the &lt;a href="http://google.github.io/googletest" target="_blank" rel="noopener noreferrer" class="external-link"&gt;GoogleTest&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; framework and I wanted a seamless integration into my workflow with &lt;a href="https://code.visualstudio.com" target="_blank" rel="noopener noreferrer" class="external-link"&gt;VScode&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and its extension &lt;a href="https://platformio.org" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PlatformIO&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Until recently, the only way to do this was to run &lt;em&gt;GoogleTest&lt;/em&gt; in parallel using the &lt;em&gt;GNU Compiler Collection&lt;/em&gt; and have it controlled by PlatformIO. Recently, however, &lt;em&gt;GoogleTest&lt;/em&gt; can be used directly as a &lt;a href="https://registry.platformio.org/libraries/google/googletest" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PlatformIO Library&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. Therefore, I&amp;rsquo;ll cover both options here.&lt;/p&gt;&#10;&lt;h2 id="using-googletest-directly-in-platformio"&gt;Using GoogleTest directly in PlatformIO&lt;/h2&gt;&#10;&lt;p&gt;The installation is particularly easy here:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Clone my Github repository &lt;a href="https://github.com/Schallbert/PlatformIO_gTestgMock/tree/googletest-native" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PlatformIO-gTestgMock&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;Open a console window in the repository folder and switch to the corresponding branch using &lt;code&gt;git checkout googletest-native&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Follow the instructions in the repository&amp;rsquo;s &lt;em&gt;README&lt;/em&gt; file and test the setup.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This is what it looks like when the tests have been successfully run:&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/platformio_gtestgmock/native_test.jpg" alt="Image: googletest result success message in PlatformIO&amp;#39;s native environment"&gt;&lt;/figure&gt;&lt;/p&gt;&#10;&lt;h2 id="using-googletest-with-gcc"&gt;Using GoogleTest with GCC&lt;/h2&gt;&#10;&lt;p&gt;You can install the required software yourself (even on a Windows&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt; computer) to get started with GoogleTest.&lt;/p&gt;&#10;&lt;h3 id="download-and-install-mingw"&gt;Download and install MinGW&lt;/h3&gt;&#10;&lt;p&gt;See &lt;a href="https://community.platformio.org/t/unit-testing-with-gtest-gmock-on-env-desktop-on-arduino-platform/14354/7" target="_blank" rel="noopener noreferrer" class="external-link"&gt;this thread&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; in the PlatformIO forum if you have any questions about the following steps.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Unix users: Skip the steps up to this point, as you probably only need the &lt;code&gt;libpthreadgc&lt;/code&gt; libraries. Check for the presence of all libraries (but omit the &lt;code&gt;mingw32-&lt;/code&gt; prefix) and install any missing ones. Make sure the &lt;code&gt;PATH&lt;/code&gt; variable is set correctly.&lt;/li&gt;&#10;&lt;li&gt;Windows users:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Download MinGW, e.g., from &lt;a href="https://sourceforge.net/projects/mingw" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Sourceforge&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;Install by running &lt;code&gt;mingw-get-setup.exe&lt;/code&gt;. If you like graphical user interfaces, leave the corresponding box checked. I &lt;em&gt;do not&lt;/em&gt; recommend changing the installation directory. If you do, you&amp;rsquo;ll need to remember the new path. &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/platformio_gtestgmock/mingw_install.jpg" alt="MinGW install"&gt;&lt;/figure&gt;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="configure-mingw"&gt;Configure MinGW&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;For GoogleTest to run, you&amp;rsquo;ll require additional packages to be installed:&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;mingw32-gcc-g++&lt;/code&gt; (of course!)&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;mingw32-libmingwex&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;mingw32-libmingwex-dll&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;mingw32-libmingwex-dev&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;mingw32-libpthreadgc-dll&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;mingw32-libpthreadgc-dev&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;To install these,&#10;&lt;ul&gt;&#10;&lt;li&gt;If you use the GUI: search libraries under &amp;ldquo;MinGW Standard Libraries&amp;rdquo;, add/activate/install them one by one.&lt;/li&gt;&#10;&lt;li&gt;Command Line Interpreter users: type &lt;code&gt;mingw-get install &lt;/code&gt; and then add the package name. Easy.&#10;If you get an error message saying that some of those do already exist, that&amp;rsquo;s good for you!&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Now you need to add MinGW&amp;rsquo;s &lt;code&gt;\bin&lt;/code&gt; folder to your system path variables so PlatformIO can find them later. If you didn&amp;rsquo;t hand-modify it, it should be &lt;code&gt;C:\MinGW\bin&lt;/code&gt;.&#10;&lt;ul&gt;&#10;&lt;li&gt;GUI fans: follow &lt;a href="https://www.architectryan.com/2018/03/17/add-to-the-path-on-windows-10" target="_blank" rel="noopener noreferrer" class="external-link"&gt;this guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;CLI: type &lt;code&gt;set PATH=%PATH%;C:\MinGW\bin&lt;/code&gt;, check if it worked with &lt;code&gt;echo %PATH:;=&amp;amp;echo.%&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Congratulations! You have installed MinGW, set its environment variables to be found more easily by third party applications, and configured it to work with GoogleTest.&lt;/p&gt;&#10;&lt;h3 id="configure-platformio"&gt;Configure PlatformIO&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;After setting the &lt;code&gt;PATH&lt;/code&gt; variable, you&amp;rsquo;ll have to restart VScode.&lt;/li&gt;&#10;&lt;li&gt;To make PlatformIO correctly link to the compiler you just install, make it use the &lt;code&gt;native&lt;/code&gt; environment where your compiler sits. Open a PlatformIO terminal within one of your projects and type &lt;code&gt;pio platform install native&lt;/code&gt; &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/platformio_gtestgmock/install_native.jpg" alt="how to install PIO native"&gt;&lt;/figure&gt;&lt;/li&gt;&#10;&lt;li&gt;Two choices:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Follow through this tutorial and use my example project as described &lt;a href="https://blog.schallbert.de/en/projects/platformio_gtestgmock/#writing-tests"&gt;below&lt;/a&gt;. You can safely skip the upcoming section as that project comes pre-configured and is open for your modifications as you have basic hardware abstraction interfaces at the ready right from the start.&lt;/li&gt;&#10;&lt;li&gt;Take your own project and modify its &lt;code&gt;Platformio.ini&lt;/code&gt; so that GoogleTest can be run.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;ol start="4"&gt;&#10;&lt;li&gt;This what you should add to your &lt;code&gt;Platformio.ini&lt;/code&gt;:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#ae81ff"&gt;env:desktop]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;platform = native&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;build_flags = &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;-&lt;span style="color:#ae81ff"&gt;std=gnu++11&lt;/span&gt; &lt;span style="color:#75715e"&gt;# use installed GNU C++11 compiler.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;-&lt;span style="color:#ae81ff"&gt;pthread&lt;/span&gt; &lt;span style="color:#75715e"&gt;# found in gtest documentation&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;lib_deps = &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#9;&lt;span style="color:#ae81ff"&gt;googletest&lt;/span&gt; &lt;span style="color:#75715e"&gt;# Will automatically load latest googletest lib&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;lib_ignore = &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;src&lt;/span&gt; &lt;span style="color:#75715e"&gt;# most main.cpp&amp;#39;s directly access the hardware. &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# Can be removed if your project is different.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;#&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# PLUS: Any files that contain bare-metal hardware commands &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;# (e.g. digitalWrite), g++ won&amp;#39;t have the headers or specialized compiler knowledge!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ae81ff"&gt;lib_compat_mode = off&lt;/span&gt; &lt;span style="color:#75715e"&gt;# Must-have for external stuff like gtest!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For the environment &lt;code&gt;desktop&lt;/code&gt; (= your computer without target), it uses the &lt;code&gt;native&lt;/code&gt; compiler which is GCC/G++ that you installed and set the &lt;code&gt;PATH&lt;/code&gt; for. GoogleTest uses C++11 standard and threading which you have to add to the build flags.&#10;Of course, the build depends on the &lt;code&gt;googletest&lt;/code&gt; library and you want to ignore all files/folders that contain source or header files using hardware-related commands or headers. Library compatibility mode has to be switched off for the Library Dependency Finder to include &lt;code&gt;googletest&lt;/code&gt;.&lt;/p&gt;&#10;&lt;ol start="5"&gt;&#10;&lt;li&gt;(Optional) In case you like using GUI buttons better than typing a line into your console to start the tests, create a custom &lt;a href="https://docs.platformio.org/en/latest/projectconf/build_configurations.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;configuration&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="running-tests"&gt;Running Tests&lt;/h3&gt;&#10;&lt;p&gt;For a quicker start, you can clone my example repository (on branch &lt;em&gt;main&lt;/em&gt;). It contains all the necessary elements to test whether &lt;code&gt;gTest&lt;/code&gt; and &lt;code&gt;gMock&lt;/code&gt; are working correctly. This allows you to immediately verify whether the installation was successful.&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Unzip the zip file or use SSH to clone the repository.&lt;/li&gt;&#10;&lt;li&gt;Open the PlatformIO extension home page, click &amp;ldquo;Open Project,&amp;rdquo; and select the folder.&lt;/li&gt;&#10;&lt;li&gt;To run the unit tests, open a PlatformIO terminal and enter &lt;code&gt;pio test -vvv -e desktop -f test_desktop&lt;/code&gt; (-vvv = verbose -e = environment, -f = filter). The parameters are required because the tests can only be run on the desktop environment, not on the microcontroller.&lt;/li&gt;&#10;&lt;li&gt;After a while, the message shown below should appear.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/platformio_gtestgmock/build_alltest_success.jpg" alt="successful build and test"&gt;&lt;/figure&gt;&#10;&lt;p&gt;Great! Unit tests within PlatformIO have been built and executed!&lt;/p&gt;&#10;&lt;h2 id="tips-and-tricks"&gt;Tips and Tricks&lt;/h2&gt;&#10;&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt; that your &lt;code&gt;PlatformIO_gTestgMock/.pio/build/desktop&lt;/code&gt; folder now contains an executable called &lt;code&gt;program.exe&lt;/code&gt;. If you run this from the CLI of your choice, you should see the same result as in the above image. You might want to choose this option more frequently once you have more tests that not only fail but crash the program on execution, and to debug tests (and not the user code), as PlatformIO&amp;rsquo;s CLI tends to hang or miss out the reason for why the test crashed. You might also want to have the stack trace printed in these cases although this would really go far beyond scope of this exercise.&lt;/p&gt;&#10;&lt;p&gt;From here,&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;you might want to continue reading below for a deeper understanding of the repository contents and how GoogleTest works&lt;/li&gt;&#10;&lt;li&gt;you could go practice writing tests by adding more cases, e.g. to verify if the &lt;code&gt;LOW&lt;/code&gt; state is looped through correctly&lt;/li&gt;&#10;&lt;li&gt;You could also enhance the interface of these groundworks, e.g. by adding the &lt;code&gt;analogRead()&lt;/code&gt; function to the hardware abstraction layer&lt;/li&gt;&#10;&lt;li&gt;You could write your project logic on top of this, cleanly cut out the hardware dependencies, and enjoy the ability for easy porting of your project to different hardware devices and manufacturers&lt;/li&gt;&#10;&lt;li&gt;Or do this other brilliant stuff that you were up to before you were diverted by this tutorial.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="repository-contents-explained"&gt;Repository contents explained&lt;/h2&gt;&#10;&lt;p&gt;The following sections cover most of the files in the repository and explain what they do in detail. The implemented business logic is minimal and should be seen as example code - to create the least confusion possible when dealing with this complex matter. For a real-life project that uses all of the patterns illustrated on this page, please refer to my &lt;a href="https://blog.schallbert.de/en/projects/tonuino/"&gt;Tonuino&lt;/a&gt; page.&lt;/p&gt;&#10;&lt;h3 id="hardware-abstraction-layer-interface"&gt;Hardware abstraction layer interface&lt;/h3&gt;&#10;&lt;p&gt;Let&amp;rsquo;s have a look at the bespoke interface and how it&amp;rsquo;s structured.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// hal_if.h&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;namespace&lt;/span&gt; hardwareAbstraction {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// Hardware Abstraction Layer Interface class&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Hal_IF&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;public&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;virtual&lt;/span&gt; &lt;span style="color:#f92672"&gt;~&lt;/span&gt;Hal_IF() &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;virtual&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;digitalRead&lt;/span&gt;(&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; pinId) &lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;virtual&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;void&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;digitalWrite&lt;/span&gt;(&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; pinId, &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; value) &lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;} &lt;span style="color:#75715e"&gt;// hardwareAbstraction&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Hal_IF class has two methods which are both &lt;code&gt;virtual&lt;/code&gt;. This means that a client class using this interface can call the method, and is thus &amp;ldquo;auto-forwarded&amp;rdquo; to the concrete implementation of the method. The &lt;code&gt;= 0;&lt;/code&gt; makes these methods &lt;em&gt;pure virtual&lt;/em&gt;, so overriding them with a derived class is not optional but a &lt;em&gt;must&lt;/em&gt;. This is what we want here, because if this method wasn&amp;rsquo;t overridden, it wouldn&amp;rsquo;t have any behavior.&#10;Note that the destructor of an interface class &lt;a href="https://stackoverflow.com/questions/3628529/should-c-interfaces-have-a-virtual-destructor" target="_blank" rel="noopener noreferrer" class="external-link"&gt;almost always should be virtual&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, too.&lt;/p&gt;&#10;&lt;h3 id="loopinout"&gt;LoopInOut&lt;/h3&gt;&#10;&lt;p&gt;The logic under test. Note that it does not directly access the input/output hardware of the µC, but the &lt;code&gt;m_hal&lt;/code&gt; variable which is an implementation of the &lt;code&gt;Hal_IF&lt;/code&gt; interface at runtime. It is passed into the class by reference (marked by the &lt;code&gt;&amp;amp;&lt;/code&gt; at the type declaration) upon object construction&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// loopInToOut.h&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;LoopInToOut&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;public&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; LoopInToOut(hardwareAbstraction&lt;span style="color:#f92672"&gt;::&lt;/span&gt;Hal_IF&lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt; hal)&lt;span style="color:#f92672"&gt;:&lt;/span&gt; m_hal(hal){};&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;~&lt;/span&gt;LoopInToOut() &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;loopThrough&lt;/span&gt;(&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; inPin, &lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; outPin);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;private&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; hardwareAbstraction&lt;span style="color:#f92672"&gt;::&lt;/span&gt;Hal_IF&lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt; m_hal;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is enabling the real magic of gMock: as the client code works with an interface, I can hand over the &amp;ldquo;real&amp;rdquo; implementation (of the hardware access) at chip runtime, but for the test I can hand over a &amp;ldquo;mock&amp;rdquo; implementation which I have control over at the time the test is running.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// loopInToOut.cpp&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; LoopInToOut&lt;span style="color:#f92672"&gt;::&lt;/span&gt;loopThrough(&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; inPin, &lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; outPin)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; input &lt;span style="color:#f92672"&gt;=&lt;/span&gt; m_hal.digitalRead(inPin);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; m_hal.digitalWrite(outPin, input);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; input;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;loopThrough()&lt;/code&gt; again is really simple. It reads the input pin&amp;rsquo;s state and maps it to the output pin. It returns the input pin state to the caller.&lt;/p&gt;&#10;&lt;h3 id="mock-hal"&gt;Mock Hal&lt;/h3&gt;&#10;&lt;p&gt;GoogleTest uses macros to define behavior. The mock class is also deriving from the interface and just provides declarations of the interface method it overrides. There&amp;rsquo;s no behavior involved just yet.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// hal_mock.h&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Hal_mock&lt;/span&gt; &lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;public&lt;/span&gt; hardwareAbstraction&lt;span style="color:#f92672"&gt;::&lt;/span&gt;Hal_IF&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;public&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; MOCK_METHOD(&lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt;, digitalRead, (&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; pinId), (&lt;span style="color:#66d9ef"&gt;const&lt;/span&gt;, &lt;span style="color:#66d9ef"&gt;override&lt;/span&gt;));&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; MOCK_METHOD(&lt;span style="color:#66d9ef"&gt;void&lt;/span&gt;, digitalWrite, (&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; pinId, &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; value), (&lt;span style="color:#66d9ef"&gt;const&lt;/span&gt;, &lt;span style="color:#66d9ef"&gt;override&lt;/span&gt;));&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="the-test-fixture"&gt;The test fixture&lt;/h4&gt;&#10;&lt;p&gt;The test fixture contains common data and behavior for all test cases within a test suite. Test suite name = class name, and this exact name has to be reused in the tests that should belong to this suite.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// unittest_loopInToOut.cpp&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;loopInToOut_Test&lt;/span&gt; &lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;public&lt;/span&gt; &lt;span style="color:#f92672"&gt;::&lt;/span&gt;testing&lt;span style="color:#f92672"&gt;::&lt;/span&gt;Test&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;protected&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;virtual&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;void&lt;/span&gt; SetUp()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; m_loopInToOut &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;new&lt;/span&gt; LoopInToOut(hal_mock);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;virtual&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;void&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;TearDown&lt;/span&gt;()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;delete&lt;/span&gt; m_loopInToOut;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; NiceMock&lt;span style="color:#f92672"&gt;&amp;lt;&lt;/span&gt;Hal_mock&lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt; hal_mock{};&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; LoopInToOut&lt;span style="color:#f92672"&gt;*&lt;/span&gt; m_loopInToOut{&lt;span style="color:#66d9ef"&gt;nullptr&lt;/span&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Thus, every test has some data prepared it can use: the class under test is set up as a pointer with a mocked implementation of its dependency.&lt;/p&gt;&#10;&lt;h3 id="writing-tests"&gt;The actual test&lt;/h3&gt;&#10;&lt;p&gt;The test is defined by the &lt;code&gt;TEST_F&lt;/code&gt; macro meaning &amp;ldquo;test with fixture&amp;rdquo; that expects test suite and test names.&#10;The &lt;code&gt;ON_CALL&lt;/code&gt; macro is a command to gMock providing behavior to the mocked class how to behave when &lt;code&gt;digitalRead(_)&lt;/code&gt; is called with any input (underscore = any): It shall return true by default.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// unittest_loopInToOut.cpp&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;TEST_F(loopInToOut_Test, loop_inputHigh_writesCorrectOutputHigh)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ON_CALL(hal_mock, digitalRead(_)).WillByDefault(Return(true));&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; EXPECT_CALL(hal_mock, digitalWrite(&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;, true));&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; m_loopInToOut&lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt;loopThrough(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then there&amp;rsquo;s an expectation expressed on the mocked class, stating that &lt;code&gt;digitalWrite&lt;/code&gt; is called with input values of &lt;code&gt;(2, true)&lt;/code&gt; which are also checked for correctness.&lt;/p&gt;&#10;&lt;p&gt;The last statement actually calls the implementation so that the expecations can be verified.&lt;/p&gt;&#10;&lt;h3 id="hardware-behavior-implementation"&gt;Hardware behavior implementation&lt;/h3&gt;&#10;&lt;p&gt;As &lt;code&gt;main.cpp&lt;/code&gt; accesses the hardware, there has to be some kind of interface implementation for this.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// hal.h&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Hal&lt;/span&gt; &lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;public&lt;/span&gt; Hal_IF&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;public&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Hal() &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;~&lt;/span&gt;Hal() &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;default&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;digitalRead&lt;/span&gt;(&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; pinId) &lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;override&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;void&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;digitalWrite&lt;/span&gt;(&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; pinId, &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; value) &lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;override&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The header states that &lt;code&gt;Hal&lt;/code&gt; derives from &lt;code&gt;Hal_IF&lt;/code&gt; and overrides both of its methods.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-c++" data-lang="c++"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// hal.cpp&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#include&lt;/span&gt; &lt;span style="color:#75715e"&gt;&amp;#34;Arduino.h&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;#include&lt;/span&gt; &lt;span style="color:#75715e"&gt;&amp;#34;hal.h&amp;#34;&lt;/span&gt;&lt;span style="color:#75715e"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;namespace&lt;/span&gt; hardwareAbstraction{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; Hal&lt;span style="color:#f92672"&gt;::&lt;/span&gt;digitalRead(&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; pinId) &lt;span style="color:#66d9ef"&gt;const&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;digitalRead&lt;/span&gt;(pinId);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;void&lt;/span&gt; Hal&lt;span style="color:#f92672"&gt;::&lt;/span&gt;digitalWrite(&lt;span style="color:#66d9ef"&gt;uint8_t&lt;/span&gt; pinId, &lt;span style="color:#66d9ef"&gt;bool&lt;/span&gt; value) &lt;span style="color:#66d9ef"&gt;const&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; digitalWrite(pinId, value);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Note that only the source file actually includes the &lt;code&gt;Arduino.h&lt;/code&gt; to access the hardware. So the dependency to hardware functions is very limited and buried in interface implementations that can be selected by the program&amp;rsquo;s structure. While &lt;code&gt;main.cpp&lt;/code&gt; will want to create an object of the above implementation, the test will want to instead hand over a mock.&lt;/p&gt;&#10;&lt;h2 id="gtest-key-concepts"&gt;Gtest key concepts&lt;/h2&gt;&#10;&lt;p&gt;Google&amp;rsquo;s official &lt;a href="http://google.github.io/googletest/primer.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;github page on GoogleTest&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; will help you a lot, not only for getting started&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;. The gmock Cookbook is an excellent guide that I have been using over the months as my tests became more complex hard to write, and the gmock Cheat Sheet will help you when you lost track of how to write a test for this and that special problem.&lt;/p&gt;&#10;&lt;h3 id="dependency-injection"&gt;Dependency Injection&lt;/h3&gt;&#10;&lt;p&gt;Dependency Injection is the basic mechanics you need to understand to make gMock work. Let me draw an image for you to make it more apparent: &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/platformio_gtestgmock/dependencyinjection.jpg" alt="Dependency Injection using interfaces"&gt;&lt;/figure&gt;&#10;The important concept is, that the interface is merely a placeholder for the implementation of the client classes&amp;rsquo; dependency. At runtime, e.g. when the host creates the client object, it will handover (i.e. inject) the implementation of the dependency into the client which then can call any of its methods, as their signatures are known at compile time due to the interface that the client is using.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The mock now merely is an implementation of the interface specialized for testing.&lt;/li&gt;&#10;&lt;li&gt;The production implementation is used in all other contexts, it features the &amp;ldquo;real&amp;rdquo; behavior of that class.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="hardware-abstraction-layer"&gt;Hardware Abstraction Layer&lt;/h3&gt;&#10;&lt;p&gt;Yep, well, this might be one of the tricky bits when working with embedded software.&#10;At some point, it boils down to outside world interaction, be it with General Purpose Input Output (GPIO) pins, accessing one of the myriad of hardware bus systems like I2C, UART, SPI, CAN to control external hardware, or simply to use system internals like the EEPROM - that&amp;rsquo;s where the trouble begins.&lt;/p&gt;&#10;&lt;p&gt;Gtest runs on GCC/G++ compiler which just doesn&amp;rsquo;t know anything what the specialized compiler of that particular chip knows. It wouldn&amp;rsquo;t even understand the header files. Now, there are two options around this issue:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Write fake headers for each µC-specific thing that can be used by GCC/G++&lt;/li&gt;&#10;&lt;li&gt;Cut! At! The! Interface! to any µC-specific thing using an (Hardware) Abstraction Layer so your production environment uses the chip hardware as it normally does, but the test environment uses mocks instead.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;There are quite some advantages for the second approach:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;It&amp;rsquo;s clean. It will force you to not cross abstraction layers which should not be crossed.&lt;/li&gt;&#10;&lt;li&gt;It&amp;rsquo;s easy. You can test your business logic without even programming your µC! Just mock the inputs and intercept the outputs, write some tests and run them in the terminal.&lt;/li&gt;&#10;&lt;li&gt;It&amp;rsquo;s portable. Exchange the implementation of your abstraction layer, and you&amp;rsquo;re almost ready to execute on a different controller family or even on another manufacturer&amp;rsquo;s chips.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="drawbacks"&gt;Drawbacks&lt;/h3&gt;&#10;&lt;p&gt;Hmm, there must be some shadow somewhere. Right you are. In my opinion, these are the main disadvantages when getting everything ready to be tested without hardware attached:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/platformio_gtestgmock/memoryfull.jpg" alt="not much memory left..."&gt;&lt;/figure&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Memory. Both ROM and RAM will be cluttered more easily when you have a massive amount of classes and vtables flying around.&lt;/li&gt;&#10;&lt;li&gt;Ownership. Injecting dependencies separates ownership from useage of an object, and you need to make up your mind how to organize both.&lt;/li&gt;&#10;&lt;li&gt;3rd party libaries. You&amp;rsquo;ll have to create an interface header, an &lt;a href="https://refactoring.guru/design-patterns/adapter/cpp/example" target="_blank" rel="noopener noreferrer" class="external-link"&gt;adapter implementation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, and a mock for each library that uses &amp;ldquo;chip-internals&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;Complexity. As your objects are injected via an interface, you&amp;rsquo;ll have to use call-by-reference or hand over by pointer 😨 now. The former is additionally limited because it will only work for constructor injection, not for any other kind (method injection, setter injection, etc.)&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="some-more-tips"&gt;Some more tips&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Keep everything &amp;ldquo;behind&amp;rdquo; your abstraction layer as simple as possible: You can only test and debug this stuff on target. Example: each method only executes one command (as in &lt;a href="https://github.com/Schallbert/PlatformIO_gTestgMock/blob/main/lib/hal/hal.cpp" target="_blank" rel="noopener noreferrer" class="external-link"&gt;my example code&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;).&lt;/li&gt;&#10;&lt;li&gt;Create a loader class or a &lt;a href="https://www.fluentcpp.com/2019/06/07/write-your-own-dependency-injection-container" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Dependency Injection Container (Nicolas Croad)&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; that holds all objects to be injected and can spit them out on request.&lt;/li&gt;&#10;&lt;li&gt;Make up your mind about the drawbacks mentioned &lt;a href="https://blog.schallbert.de/en/projects/platformio_gtestgmock/#drawbacks"&gt;above&lt;/a&gt; and how you create your low-level Architecture to accomodate for this.&lt;/li&gt;&#10;&lt;li&gt;Don&amp;rsquo;t hesitate to create more than one level of interfaces. It might be a good idea to use this pattern not only for the direct hardware interaction, but also in between software modules, which then can be tested in a higher granularity so your overall progress might even be quicker versus one big ball of classes you can just control via mocks in the Hardware Abstraction Layer.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="tough-stuff"&gt;Tough Stuff!&lt;/h3&gt;&#10;&lt;p&gt;You earn a break now. This was much information to deal with. Take your time. Contact me on this topic&amp;rsquo;s &lt;a href="https://github.com/Schallbert/PlatformIO_gTestgMock/discussions/1" target="_blank" rel="noopener noreferrer" class="external-link"&gt;discussions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; page if you feel something is missing, incorrect, to be improved, or just exactly what you needed right now.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Schallbert&lt;/strong&gt;&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;Installation under Linux is actually more easy, as &lt;code&gt;gcc&lt;/code&gt; and &lt;code&gt;g++&lt;/code&gt; come already pre-installed. So even if they do not feature the correct packages yet to support GoogleTest, their error message will be of much better help than the ones you get on Windows when some libraries are missing&amp;hellip; 🙃&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:2"&gt;&#10;&lt;p&gt;This is also called &lt;a href="https://github.com/ninject/Ninject/wiki/Injection-Patterns" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Constructor Injection&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, a subtype of &lt;a href="https://github.com/ninject/ninject/wiki/Dependency-Injection-By-Hand" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Dependency Injection&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; which is a means to achieve &lt;a href="https://en.wikipedia.org/wiki/Inversion_of_control" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Inversion Of Control&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. I have prepared some links for you to continue reading, it is a rich topic that whole books have been written about.`&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:3"&gt;&#10;&lt;p&gt;By The way: the googletest site is also static like mine and it uses Jekyll as well 👋 &amp;hellip;do you like it? I&amp;rsquo;d like to see your opinion &lt;a href="https://github.com/Schallbert/schallbert.github.io/discussions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;on my discussions page&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>QR-Codengrave</title><link>https://blog.schallbert.de/en/projects/qr-codengrave/</link><pubDate>Mon, 01 Jan 0001</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/projects/qr-codengrave/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/qr-codengrave/app-thumb.jpg"&#10; class="post-cover"&#10; alt="image: qr-code created with my CNC portal milling machine"&#10; title="QR-Codengrave" /&gt;&#10;&lt;h2 id="project-stats"&gt;Project stats&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Difficulty: hard 4/5&lt;/li&gt;&#10;&lt;li&gt;Cost: 0€&lt;/li&gt;&#10;&lt;li&gt;Time: ~50h&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="the-idea"&gt;The idea&lt;/h2&gt;&#10;&lt;p&gt;A friend asked me to fabricate a coaster for him. A custom logo, some letters. Nothing too interesting. On the backside, he wanted to have a &lt;a href="https://en.wikipedia.org/wiki/QR_code" target="_blank" rel="noopener noreferrer" class="external-link"&gt;QR-code&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; engraved that keeps a link to his website.&lt;/p&gt;&#10;&lt;p&gt;So I had a QR-code created with a &lt;a href="https://www.nayuki.io/page/qr-code-generator-library" target="_blank" rel="noopener noreferrer" class="external-link"&gt;standard web-tool&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and exported as &lt;code&gt;.svg&lt;/code&gt;. Unfortunately, in my CAM I&amp;rsquo;d have to klick every single pixel and create a carve action for that pixel, which would take quite some minutes of my time for click-select-and-enter-value procedures. Plus, I&amp;rsquo;d have to use a tapered tool and V-carve to get a square pixel which takes much more machining time than a round pixel representation.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/qr-codengrave/svg_path_issues.jpg" alt="Image: QR-code SVG in my CAM"&gt;&lt;/figure&gt;&#10;&lt;p&gt;That&amp;rsquo;s where I decided to throw many, many more hours of my time at this problem and come up with a generic solution for QR-code engravings - a piece of software named &lt;strong&gt;QR-codengrave&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;h2 id="getting-started"&gt;Getting started&lt;/h2&gt;&#10;&lt;h3 id="specification"&gt;Specification&lt;/h3&gt;&#10;&lt;p&gt;This is what I wanted the tool to do:&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;Must-haves:&lt;/em&gt;&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Take a text input and convert it to a QR-code. No additional config to be done by the user.&lt;/li&gt;&#10;&lt;li&gt;Provide an option to manage different CNC tools.&lt;/li&gt;&#10;&lt;li&gt;Provide inputs for engrave parameters: engrave depth, in-job rapid movement hover overs, flyover height&lt;/li&gt;&#10;&lt;li&gt;Provide inputs for custom workpiece XY0-offsets&lt;/li&gt;&#10;&lt;li&gt;Vectorize the QR-code&amp;rsquo;s pixel output&lt;/li&gt;&#10;&lt;li&gt;Have paths created from the vectors, for simplicity start with an inwards spiral&lt;/li&gt;&#10;&lt;li&gt;Handover the paths to a module that uses the selected tool&amp;rsquo;s parameters to create machine paths&lt;/li&gt;&#10;&lt;li&gt;Have the machine paths converted to G-code that can later be interpreted by my machine&lt;/li&gt;&#10;&lt;li&gt;Thoroughly verify the algorithms with automated unit tests (no crashes in the physical world please)&lt;/li&gt;&#10;&lt;li&gt;Provide a graphical user interface to make parameter input as simple as possible&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;&lt;em&gt;Optionals&lt;/em&gt;&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Have a Continuous Integration pipeline created both for my IDE and Github Actions&lt;/li&gt;&#10;&lt;li&gt;Have a Continuous Deploy pipeline created for my IDE (PyInstaller) and Github Actions (Artifacts, releases)&lt;/li&gt;&#10;&lt;li&gt;Add integration tests for the GUI&lt;/li&gt;&#10;&lt;li&gt;Optimize the path generation algorithm as &amp;ldquo;inwards spiral&amp;rdquo; has a high manufacturing quality and is nice to look at but is not very efficient.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="uncertainities"&gt;Uncertainities&lt;/h3&gt;&#10;&lt;p&gt;At the beginning of this project, I had some open questions and risks that could have limited usability of the program or even make the whole project unfeasible. They all turned out to be wrong (thankfully), but still these were the risks that could have rendered all my work useless:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Cameras won&amp;rsquo;t read round dot-as-pixels QR-codes&lt;/li&gt;&#10;&lt;li&gt;The contrast my engraving materials provide is too low so read results could be erratic or not obtainable&lt;/li&gt;&#10;&lt;li&gt;Machining time could be very high, making the project uneconomic&lt;/li&gt;&#10;&lt;li&gt;Limitations of programming tools or my abilities to code could increase development time to where it&amp;rsquo;s not worth putting more effort into&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;That&amp;rsquo;s where I decided to write &lt;a href="https://blog.schallbert.de/en/projects/qr-codengrave/#unit-tests"&gt;unit tests&lt;/a&gt; along with the code to get quick feedback about whether my code could do what it should. And I limited the program&amp;rsquo;s functionality to one engraving strategy and the GUI to only show a preview of the QR-code, not XY-offsets or other fancy stuff for a start.&lt;/p&gt;&#10;&lt;h3 id="programming-language"&gt;Programming language&lt;/h3&gt;&#10;&lt;p&gt;I took some sheets of paper and noted down the essentials.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/qr-codengrave/sketches.jpg" alt="Image: Software planning on paper"&gt;&lt;/figure&gt;&#10;&lt;p&gt;One module shall take care of creating a QR-code from text input, another one should create vectors and paths to have that QR-code engraved, and a GUI in the middle shall host the text input, a machining preview, and input options for milling tools, engrave parameters, and workpiece offsets.&lt;/p&gt;&#10;&lt;p&gt;I had a couple of programming languages that I &amp;ldquo;speak&amp;rdquo; well enough to choose from:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;C++ with QT framework&lt;/li&gt;&#10;&lt;li&gt;Java&lt;/li&gt;&#10;&lt;li&gt;Python with Tkinter framework&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I decided on the - in my view - most simple solution to have a running prototype as quickly as possible: Python with Tkinter. I know tkinter from an &lt;a href="https://github.com/Schallbert/DerWinterNaht" target="_blank" rel="noopener noreferrer" class="external-link"&gt;earlier project&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; of mine and although it has its caveats and limitations, it&amp;rsquo;s still powerful and quick to setup.&lt;/p&gt;&#10;&lt;h3 id="proof-of-concept"&gt;Proof of concept&lt;/h3&gt;&#10;&lt;p&gt;I did some research and found a pretty good open source &lt;a href="https://www.nayuki.io/page/qr-code-generator-library" target="_blank" rel="noopener noreferrer" class="external-link"&gt;QR-code library on the web&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; that&amp;rsquo;s available also in Python. Based on its output, I created a vectorizing algorithm that could interrogate the QR-code library to return values of the QR-code&amp;rsquo;s pixels in a scan line:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;_qr_bitstream_from_line&lt;/span&gt;(self, line):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&amp;#34;Creates a bitstream from an input line of a QR-code data representation&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; :param line: a QR-code data representation (line of bits within the QR-code)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; :returns bitstream: returns an array of bits reflecting the QR code&amp;#39;s state at the respective&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; point of the line.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; bitstream &lt;span style="color:#f92672"&gt;=&lt;/span&gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_direction() &lt;span style="color:#f92672"&gt;==&lt;/span&gt; Direction&lt;span style="color:#f92672"&gt;.&lt;/span&gt;RIGHT:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; x &lt;span style="color:#f92672"&gt;in&lt;/span&gt; range(line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_start()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;x, line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_end()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;x &lt;span style="color:#f92672"&gt;+&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; bitstream&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_qr&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_module(x, abs(line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_start()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;y)))&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_direction() &lt;span style="color:#f92672"&gt;==&lt;/span&gt; Direction&lt;span style="color:#f92672"&gt;.&lt;/span&gt;LEFT:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; x &lt;span style="color:#f92672"&gt;in&lt;/span&gt; range(line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_start()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;x, line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_end()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;x &lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;, &lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; bitstream&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_qr&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_module(x, abs(line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_start()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;y)))&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_direction() &lt;span style="color:#f92672"&gt;==&lt;/span&gt; Direction&lt;span style="color:#f92672"&gt;.&lt;/span&gt;UP:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; y &lt;span style="color:#f92672"&gt;in&lt;/span&gt; range(line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_start()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;y, line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_end()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;y &lt;span style="color:#f92672"&gt;+&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; bitstream&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_qr&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_module(line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_start()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;x, abs(y)))&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_direction() &lt;span style="color:#f92672"&gt;==&lt;/span&gt; Direction&lt;span style="color:#f92672"&gt;.&lt;/span&gt;DOWN:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; y &lt;span style="color:#f92672"&gt;in&lt;/span&gt; range(line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_start()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;y, line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_end()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;y &lt;span style="color:#f92672"&gt;-&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;, &lt;span style="color:#f92672"&gt;-&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; bitstream&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_qr&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_module(line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_p_start()&lt;span style="color:#f92672"&gt;.&lt;/span&gt;x, abs(y)))&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; bitstream&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then, I had to create vectors from these lines, so that consequent pixels with the same values could be merged to a line that&amp;rsquo;s much quicker to manufacture than multiple dots:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;_vectorize_bitstream&lt;/span&gt;(self, bitstream):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&amp;#34;Creates a QrLineData object from an input bistream&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; :param bitstream: an array of bits = a line of the QR-code&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; :returns line_vector: A list of QrLineData objects&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; line_vector &lt;span style="color:#f92672"&gt;=&lt;/span&gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; data &lt;span style="color:#f92672"&gt;=&lt;/span&gt; QrLineData(bitstream[&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;], &lt;span style="color:#66d9ef"&gt;True&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; bit &lt;span style="color:#f92672"&gt;in&lt;/span&gt; range(&lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;, len(bitstream)):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; bitstream[bit] &lt;span style="color:#f92672"&gt;==&lt;/span&gt; data&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_state():&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; data&lt;span style="color:#f92672"&gt;.&lt;/span&gt;add_length()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; line_vector&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(data)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; data &lt;span style="color:#f92672"&gt;=&lt;/span&gt; QrLineData(bitstream[bit])&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#f92672"&gt;not&lt;/span&gt; data&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_state():&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; data&lt;span style="color:#f92672"&gt;.&lt;/span&gt;finalize()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; line_vector&lt;span style="color:#f92672"&gt;.&lt;/span&gt;append(data)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; line_vector&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;These two methods would now be called by a supervising method that knows a spiral path through the QR-code.&#10;My first GUI just had three elements: An entry field for the text to be converted to a QR-code, a button to run the algorithm, and an output canvas where the algorithm&amp;rsquo;s engrave path would be drawn.&lt;/p&gt;&#10;&lt;p&gt;By the way, I used the &lt;a href="https://docs.python.org/3/library/turtle.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Turtle&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; module to draw the vectorized QR-code path, a tool that&amp;rsquo;s been written for college kids who take a dip into program lamguages like Python. It is slow but was easy to setup so that was enough for me to get started with. I hard-coded it in to save time, and my loops are more C-ish than pythonic but nevermind.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; vect &lt;span style="color:#f92672"&gt;in&lt;/span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_spiral_path[i]&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_z_vector():&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_stop_draw:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;break&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; length &lt;span style="color:#f92672"&gt;=&lt;/span&gt; vect&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_length()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; vect&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_state():&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;turtle&lt;span style="color:#f92672"&gt;.&lt;/span&gt;down()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;turtle&lt;span style="color:#f92672"&gt;.&lt;/span&gt;forward(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;pen_size &lt;span style="color:#f92672"&gt;*&lt;/span&gt; length)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;turtle&lt;span style="color:#f92672"&gt;.&lt;/span&gt;up()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;else&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;turtle&lt;span style="color:#f92672"&gt;.&lt;/span&gt;forward(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;pen_size &lt;span style="color:#f92672"&gt;*&lt;/span&gt; length)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;turtle&lt;span style="color:#f92672"&gt;.&lt;/span&gt;right(&lt;span style="color:#ae81ff"&gt;90&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This very simple concept looked promising to me but I had to tweak it a couple of hours because my initial output looked like this:&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center media-frame--video"&gt;&#10; &lt;div class="media-video"&gt;&lt;video controls&gt;&#10; &lt;source src="https://blog.schallbert.de/assets/video/qr-codengrave/drawing_issue_cut.mp4" type="video/mp4"&gt;&#10; Your browser does not support the video tag.&#10; &lt;/video&gt;&lt;/div&gt;&#10; &lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;QR-codengrave GUI test&lt;/span&gt;&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;p&gt;Still, after a while I was able to reliably draw a (21x21 Pixel) QR-code on screen, and use my smartphone&amp;rsquo;s camera to yield the webpage link. 🥳 Yeah!&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center media-frame--video"&gt;&#10; &lt;div class="media-video"&gt;&lt;video controls&gt;&#10; &lt;source src="https://blog.schallbert.de/assets/video/qr-codengrave/working_scan.mp4" type="video/mp4"&gt;&#10; Your browser does not support the video tag.&#10; &lt;/video&gt;&lt;/div&gt;&#10; &lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;QR-codengrave vectorizer working&lt;/span&gt;&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;h3 id="unit-tests"&gt;Unit tests&lt;/h3&gt;&#10;&lt;p&gt;The &amp;ldquo;platform&amp;rdquo; parts - &lt;code&gt;vectorize_qr&lt;/code&gt; and &lt;code&gt;machinify_vector&lt;/code&gt; modules both contain a lot of verifiable calculations and business logic. That&amp;rsquo;s why my tests with Python&amp;rsquo;s standard &lt;a href="https://docs.python.org/3/library/unittest.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;unittest&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; module are quick and easy to write. This is an example where I verify that the line class I created correctly calculates a length (n+1 style):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;TestLine&lt;/span&gt;(unittest&lt;span style="color:#f92672"&gt;.&lt;/span&gt;TestCase):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;test_line_x_calculates_valid_length&lt;/span&gt;(self):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; line &lt;span style="color:#f92672"&gt;=&lt;/span&gt; Line(Point(&lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;), Point(&lt;span style="color:#ae81ff"&gt;5&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;))&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;assertEqual(&lt;span style="color:#ae81ff"&gt;6&lt;/span&gt;, line&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_abs_length())&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But it quickly became more complex when I wanted to test whether the output of the QR-Code generator library is handled correctly in my program. For that I had to inject the library into my class under test so I could sneak a mock implementation in that&amp;rsquo;s then interrogated from within the test.&lt;/p&gt;&#10;&lt;p&gt;This pattern of dependency injection is very similar to how it&amp;rsquo;s done in &lt;code&gt;C++&lt;/code&gt; where I&amp;rsquo;m much more proficient (see a project &lt;a href="https://blog.schallbert.de/en/projects/platformio_gtestgmock/#dependency-injection"&gt;here&lt;/a&gt; or another one &lt;a href="https://github.com/Schallbert/Tonuino_alternativeFirmware" target="_blank" rel="noopener noreferrer" class="external-link"&gt;there&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, for example), only that in Python far less boilerplate code is needed to get there. Some internet research helped me write the following test case:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;test_bitstream_qr_input_loop_xpos_correct&lt;/span&gt;(self):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; mock_qr &lt;span style="color:#f92672"&gt;=&lt;/span&gt; QrCode&lt;span style="color:#f92672"&gt;.&lt;/span&gt;encode_text(&lt;span style="color:#e6db74"&gt;&amp;#34;schallbert.de&amp;#34;&lt;/span&gt;, QrCode&lt;span style="color:#f92672"&gt;.&lt;/span&gt;Ecc&lt;span style="color:#f92672"&gt;.&lt;/span&gt;MEDIUM)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; mock_qr&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_module &lt;span style="color:#f92672"&gt;=&lt;/span&gt; MagicMock()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cam &lt;span style="color:#f92672"&gt;=&lt;/span&gt; VectorizeQr(mock_qr, &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cam&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_qr_bitstream_from_line(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;test_line_x)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; mock_qr&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_module&lt;span style="color:#f92672"&gt;.&lt;/span&gt;assert_called_with(&lt;span style="color:#ae81ff"&gt;20&lt;/span&gt;, &lt;span style="color:#ae81ff"&gt;0&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I inject the &amp;ldquo;real&amp;rdquo; &lt;code&gt;QrCode&lt;/code&gt; library but with one mock method which is of interest to me, &lt;code&gt;get_module()&lt;/code&gt;. In the test, I&amp;rsquo;m asserting that it is called with the expected parameters from within my class under test.&lt;/p&gt;&#10;&lt;p&gt;With these tests in place, I can be pretty sure that the building blocks of my algorithm work as intended, and that regressions are quick to be found.&lt;/p&gt;&#10;&lt;h2 id="the-long-way-to-minimum-viable-product"&gt;The long way to Minimum Viable Product&lt;/h2&gt;&#10;&lt;p&gt;Boosted by this quick win, I went all-in. I had to design a tool selector before implementing the G-code generator because I&amp;rsquo;d have to have the tool properties available before setting dimensions and tool speed / feed for the QR-code engraving action.&lt;/p&gt;&#10;&lt;p&gt;After some unsuccessful tries in the main window, I decided the tool config window should be a dialog instead (that Tkinter calls &lt;a href="https://www.pythontutorial.net/tkinter/tkinter-toplevel/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Toplevel&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;) so tool parameters can be entered and sent back to the main GUI once complete. I made the dialog modal with&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_dialog&lt;span style="color:#f92672"&gt;.&lt;/span&gt;grab_set()&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So it would always have focus to avoid trigger several instances of this window.&#10;I keep the configured tools in a numbered list so once fed, the application would provide the list so the user could select the required tool for the job from a dropdown menu.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/qr-codengrave/tool_config.jpg" alt="Image: QR-codengrave&amp;#39;s tool configuration dialog"&gt;&lt;/figure&gt;&#10;&lt;p&gt;With this step, I had to implement two things:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Checks for value type and range&lt;/li&gt;&#10;&lt;li&gt;A Persistence module so that entered tools would survice an application restart.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="value-type-and-range-checks"&gt;Value type and range checks&lt;/h3&gt;&#10;&lt;p&gt;This could be accomplished using an event hook within the Entry Box widget like so:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; reg &lt;span style="color:#f92672"&gt;=&lt;/span&gt; config_tool_frame&lt;span style="color:#f92672"&gt;.&lt;/span&gt;register(validate_number)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; tool_nr_entry&lt;span style="color:#f92672"&gt;.&lt;/span&gt;config(validate&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;key&amp;#34;&lt;/span&gt;, validatecommand&lt;span style="color:#f92672"&gt;=&lt;/span&gt;(reg, &lt;span style="color:#e6db74"&gt;&amp;#39;%P&amp;#39;&lt;/span&gt;))&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And then handing over the entered value to a simple validator that just tries to cast the input value into a float and returns False if that generates an error,&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;validate_number&lt;/span&gt;(entry):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&amp;#34;Helper function that is used in validators to check&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; that the entered keystroke is a number&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; entry &lt;span style="color:#f92672"&gt;==&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#39;&amp;#39;&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;True&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;try&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; float(entry)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;True&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;except&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;ValueError&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;False&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Range checks would be made not when something is typed into the fields, but only when the &lt;code&gt;OK&lt;/code&gt; button is pressed to relay the entered data back into the application. Once out of range, a warning Popup (&lt;code&gt;tkinter.messagebox&lt;/code&gt; module) would appear pointing to the possible issue.&lt;/p&gt;&#10;&lt;h3 id="persistence"&gt;Persistence&lt;/h3&gt;&#10;&lt;p&gt;The Persistence module was a bit less easy to implement.&#10;I decided on creating an item what C++ guys would call a &lt;em&gt;static class&lt;/em&gt;, i.e. a class that doesn&amp;rsquo;t have to be instantiated to call one of its methods. This class just has two methods called &lt;code&gt;load&lt;/code&gt; and &lt;code&gt;save&lt;/code&gt; that do what their name suggests using Python&amp;rsquo;s &lt;a href="https://docs.python.org/3/library/pickle.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;pickle&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for serialization of objects that I wanted to be persisted. In Python, I used the &lt;code&gt;@classmethod&lt;/code&gt; decorator to indicate the static nature of that class.&lt;/p&gt;&#10;&lt;p&gt;Have a look at how simple data serialization can become with the right tools:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;with&lt;/span&gt; open(app_persistence_path, &lt;span style="color:#e6db74"&gt;&amp;#39;wb&amp;#39;&lt;/span&gt;) &lt;span style="color:#66d9ef"&gt;as&lt;/span&gt; file:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; pickle&lt;span style="color:#f92672"&gt;.&lt;/span&gt;dump([cls&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_tool_list,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cls&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_z_params,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; cls&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_xy0],&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; file, protocol&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;When this reliably worked, I added an engrave parameter configuration widget.&lt;/p&gt;&#10;&lt;h3 id="engrave-parameters"&gt;Engrave parameters&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/qr-codengrave/engrave_params.jpg" alt="Image: QR-codengrave&amp;#39;s engrave parameter configuration dialog"&gt;&lt;/figure&gt;&#10;To generate G-code from my QR-code data, I not only needed tool and speed / feed data, but also I&amp;rsquo;d have to know how deep to engrave, how far above the workpiece&amp;rsquo;s surface the tool should hover for rapid moves, and what safety height to use for the CNC to return to origin or home.&lt;/p&gt;&#10;&lt;p&gt;It is created in a similar manner to the &lt;code&gt;tool_configuration&lt;/code&gt; dialog and uses the same mechanisms for type and range checking. The only difference is that it&amp;rsquo;s launched not with a button click, but via click on a label that shows the current engrave parameter value in the main window.&lt;/p&gt;&#10;&lt;h3 id="workpiece-xy-zero-offset"&gt;Workpiece XY Zero offset&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/qr-codengrave/xy0_config.jpg" alt="Image: QR-codengrave&amp;#39;s workpiece offset configuration dialog"&gt;&lt;/figure&gt;&#10;The workpiece offset config dialog features some radiobuttons to select most common workpiece origins, and provides a custom option where the user can enter values for workpiece&amp;rsquo;s X and Y axis zero.&#10;A function in the background calculates the required offset relative to the engrave spiral&amp;rsquo;s starting point.&lt;/p&gt;&#10;&lt;p&gt;Normally, I wouldn&amp;rsquo;t want the GUI to do calculations or a lot of branching logic because it&amp;rsquo;s harder to verify as test fixtures become larger and more difficult to maintain. Still, in this case I decided to do the offset calculation with help of a lookup table within the GUI module to avoid having to communicate through all GUI modules to platform and back (4 classes total comms length).&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;_get_xy_offset&lt;/span&gt;(self, offset):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&amp;#34;calculates an XY coordinate offset from a preset point, taking the selected tool diameter into account.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; :param offset the selected offset from the above enum class&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; :returns a XY Point where XY0 is assumed for the engraving.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; qr &lt;span style="color:#f92672"&gt;=&lt;/span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_qr_dimension&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; d &lt;span style="color:#f92672"&gt;=&lt;/span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;_tool_diameter&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; offsets &lt;span style="color:#f92672"&gt;=&lt;/span&gt; {Offset&lt;span style="color:#f92672"&gt;.&lt;/span&gt;CENTER: Point((d &lt;span style="color:#f92672"&gt;-&lt;/span&gt; qr) &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;, (qr &lt;span style="color:#f92672"&gt;-&lt;/span&gt; d) &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Offset&lt;span style="color:#f92672"&gt;.&lt;/span&gt;TOPLEFT: Point(d &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;, &lt;span style="color:#f92672"&gt;-&lt;/span&gt;d &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Offset&lt;span style="color:#f92672"&gt;.&lt;/span&gt;TOPRIGHT: Point(d &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt; qr, &lt;span style="color:#f92672"&gt;-&lt;/span&gt;d &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Offset&lt;span style="color:#f92672"&gt;.&lt;/span&gt;BOTTOMLEFT: Point(d &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;, qr &lt;span style="color:#f92672"&gt;-&lt;/span&gt; d &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Offset&lt;span style="color:#f92672"&gt;.&lt;/span&gt;BOTTOMRIGHT: Point(d &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt; &lt;span style="color:#f92672"&gt;-&lt;/span&gt; qr, qr &lt;span style="color:#f92672"&gt;-&lt;/span&gt; d &lt;span style="color:#f92672"&gt;/&lt;/span&gt; &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; offset &lt;span style="color:#f92672"&gt;in&lt;/span&gt; offsets:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; offsets[offset]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="initial-release-v10"&gt;Initial release V1.0&lt;/h2&gt;&#10;&lt;p&gt;The initial release is a minimum viable product. It does its job but still has some flaws (like throwing an error when there&amp;rsquo;s no tool selected in the drop-down) and imperfections (like drawing a QR-code is really slow, re-drawing tilts the QR-code a bit etc.) but it should just work, helping the user avoid the worst blunders on their way.&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/qr-codengrave/main_application.jpg" alt="Image: QR-codengrave&amp;#39;s main screen"&gt;&lt;/figure&gt;&#10;&lt;p&gt;I have tested the execution on my CNC machine and at least my phone is able to navigate to a website just by holding the camera at the workpiece 📱&lt;/p&gt;&#10;&lt;h2 id="automated-integration-tests-for-the-gui-frustrating-work"&gt;Automated integration tests for the GUI: frustrating work&lt;/h2&gt;&#10;&lt;p&gt;When my initial release was out, I wanted to add some tests related to the GUI. As I didn&amp;rsquo;t manage to have absolutely no business logic there, I at least wanted to verify the range checks as well as the communication with the platform to ensure that it all works as intended.&lt;/p&gt;&#10;&lt;h3 id="stuck-in-messagebox"&gt;Stuck in Messagebox&lt;/h3&gt;&#10;&lt;p&gt;For that, I wrote some tests that ran on my local machine with the disadvantage that when the test hit a warning or error dialog &lt;code&gt;messagebox.showinfo / messagebox.showerror&lt;/code&gt;, a manual user interaction was absolutely required to have the window closed and the test to continue.&lt;/p&gt;&#10;&lt;p&gt;I didn&amp;rsquo;t find an easy way to circumvent that issue as the messagebox acts as an independent widget that cannot easily be tampered with. Even my tries to invoke a keystroke as a side effect from within the test did not prove to be fruitful (and, in the end, wouldn&amp;rsquo;t have been a clean sollution, I suppose).&lt;/p&gt;&#10;&lt;p&gt;My solution after a couple of hours cursing under my breath is that the messagebox widgets are injected into the GUI classes that use it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@patch&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#39;src.gui.gui_tool_manage.GuiToolManager&amp;#39;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;setUp&lt;/span&gt;(self, mock_guitoolmanager):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; tk&lt;span style="color:#f92672"&gt;.&lt;/span&gt;Tk() &lt;span style="color:#75715e"&gt;# required to have tk variables properly instantiated&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;mock_guitoolmanager &lt;span style="color:#f92672"&gt;=&lt;/span&gt; mock_guitoolmanager&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;mock_msg &lt;span style="color:#f92672"&gt;=&lt;/span&gt; MsgBox()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;mock_msg&lt;span style="color:#f92672"&gt;.&lt;/span&gt;showinfo &lt;span style="color:#f92672"&gt;=&lt;/span&gt; MagicMock()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; Persistence&lt;span style="color:#f92672"&gt;.&lt;/span&gt;set_mock_msgbox(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;mock_msg)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;config_tool &lt;span style="color:#f92672"&gt;=&lt;/span&gt; GuiConfigureTool(self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;mock_guitoolmanager, self&lt;span style="color:#f92672"&gt;.&lt;/span&gt;mock_msg, {&lt;span style="color:#e6db74"&gt;&amp;#39;padx&amp;#39;&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;5&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#39;pady&amp;#39;&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;5&lt;/span&gt;})&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This way, I can throw &lt;code&gt;mock_msg&lt;/code&gt; into the gui class so not the real showinfo was taken but instead the mock would know whether it has been called as intended. I could add it to the test fixture so my test code itself could stay &lt;a href="https://en.wikipedia.org/wiki/Don%27t_repeat_yourself" target="_blank" rel="noopener noreferrer" class="external-link"&gt;DRY&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;It adds more arguments to the constructor and delegates the instantiation to a higher level class, but still the usage inside the class under test stays trivial.&lt;/p&gt;&#10;&lt;h3 id="static-class-issues"&gt;Static class issues&lt;/h3&gt;&#10;&lt;p&gt;As the persistence class I mentioned &lt;a href="https://blog.schallbert.de/en/projects/qr-codengrave/#persistence"&gt;above&lt;/a&gt; is &lt;em&gt;static&lt;/em&gt; in a way that it doesn&amp;rsquo;t require instantiation, I had the next issue built in my code right from the start: An integration test would now try to write to an actual &lt;code&gt;persistence.dat&lt;/code&gt; file, a dependency that I didn&amp;rsquo;t want to have, especially not in the continuous integration environment.&lt;/p&gt;&#10;&lt;p&gt;So I moved Persistence up the stream into the main GUI file that wouldn&amp;rsquo;t be tested following &lt;a href="https://blog.stevensanderson.com/2009/11/04/selective-unit-testing-costs-and-benefits/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Steven Anderson&amp;rsquo;s splendid blog post&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. The main GUI should only contain declarative code anyways, and the loaded data is then cascaded into the classes that use it instead of the persistence class that actually does the load/save operation.&lt;/p&gt;&#10;&lt;h2 id="future-releases"&gt;Future releases&lt;/h2&gt;&#10;&lt;p&gt;This is what I&amp;rsquo;m planning for future releases:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Drawing speed of the QR-code must be increased&lt;/li&gt;&#10;&lt;li&gt;Graphic bugs (scaling, tilting) and blurry edges of drawing screen to be fixed&lt;/li&gt;&#10;&lt;li&gt;CNC paths to be optimized for lower manufacturing time&lt;/li&gt;&#10;&lt;li&gt;Bugfixes&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;At least if there are any; let&amp;rsquo;s see how much I&amp;rsquo;ll be using this tool. The things that annoy me most will likely be fixed first.&lt;/p&gt;&#10;&lt;p&gt;Ah, one last thing: be rewarded for following through on this article. You can download your copy of &lt;a href="https://github.com/Schallbert/QR-codengrave/releases/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;QR-codengrave&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; for free to play around with, and for home use. If you&amp;rsquo;re planning for commercial use, please let me know upfront.&lt;/p&gt;&#10;</description></item><item><title>This site Demo</title><link>https://blog.schallbert.de/en/projects/thissite/</link><pubDate>Mon, 01 Jan 0001</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/projects/thissite/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/thissite/desktop-thumb.jpg"&#10; class="post-cover"&#10; alt="Schallbert&amp;#39;s Desktop"&#10; title="This site Demo" /&gt;&#10;&lt;h2 id="project-stats"&gt;Project stats&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Difficulty: medium 3/5&lt;/li&gt;&#10;&lt;li&gt;Cost: 0€&lt;/li&gt;&#10;&lt;li&gt;Time: ~15h&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="the-idea"&gt;The idea&lt;/h2&gt;&#10;&lt;p&gt;The idea originated through my &lt;a href="https://blog.schallbert.de/en/projects/mobfobamp/"&gt;MobFobAmp&lt;/a&gt; project of which I didn&amp;rsquo;t have a github repository as there was no code or similar documentation to keep. So I decided I wanted a website so I could host instructions for this Open Hardware project for others to build their own copy. I wanted an easy-to-host solution that wouldn&amp;rsquo;t take too much work to set up. As I don&amp;rsquo;t have an own server, I asked for help. &lt;a href="https://github.com/moritzmar" target="_blank" rel="noopener noreferrer" class="external-link"&gt;moritzmar&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; recommended to use github pages as a site host to start with for easy auto-deployment and global scope. So I gave it a try.&lt;/p&gt;&#10;&lt;h2 id="getting-started"&gt;Getting started&lt;/h2&gt;&#10;&lt;p&gt;I figured out that a static site would be the right thing to use, without the need for a database or huge Content Management frameworks as my site layout should be simple and minimalistic; I just wanted to host this one project in the beginning.&#10;&lt;a href="https://docs.github.com/en/pages/setting-up-a-github-pages-site-with-jekyll/about-github-pages-and-jekyll" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Github recommended to use Jekyll&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://upload.wikimedia.org/wikipedia/commons/4/42/Jekyll_%28software%29_Logo.png" alt="Jekyll Logo"&gt;&lt;figcaption class="media-caption"&gt;&#10; &lt;span class="caption-text"&gt;Jekyll Logo&lt;/span&gt;&lt;a&#10; href="https://commons.wikimedia.org/wiki/File:Jekyll_%28software%29_Logo.png#file"&#10; class="attr-link"&#10; aria-label="Attribution 1"&#10; &gt;&#10; &lt;sup class="attr-id"&gt;[1]&lt;/sup&gt;&#10; &lt;/a&gt;&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;p&gt;as a site generator. I had some trouble with the Windows Subsystem for Linux (WSL) as it wouldn&amp;rsquo;t accept my terminal inputs at some point, so I followed the &lt;a href="https://jekyllrb.com/docs/installation/windows/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and used the RubyInstaller which worked right out of the box for me.&lt;/p&gt;&#10;&lt;h2 id="site-setup"&gt;Site setup&lt;/h2&gt;&#10;&lt;p&gt;I realized that I might want to document more of my projects, and not only the &amp;ldquo;hardware&amp;rdquo; ones. That&amp;rsquo;s why I came up with the idea of grouping projects together, each with a header, a little teaser text, and a thumbnail image. Clicking on which would lead to the individual project page. Which would involve a lot of programming for page setup and layouting which I didn&amp;rsquo;t want to spend too much time on - my other projects were waiting&amp;hellip;&lt;/p&gt;&#10;&lt;h2 id="first-try-with-themes"&gt;First try with Themes&lt;/h2&gt;&#10;&lt;p&gt;So I followed another suggestion by Github pages: The usage of pre-defined &amp;ldquo;Themes&amp;rdquo;.&#10;I first used the &lt;a href="http://jekyllthemes.org/themes/agency/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Agency Theme&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. This one has a great landing page with a huge picture and it supports collections that enable the group-the-project thing that I wanted. Unfortunately, the theme is a &amp;ldquo;remote theme&amp;rdquo; so customization was limited, e.g. I didn&amp;rsquo;t manage to have a colletion point to another page, it would always open a &amp;ldquo;popup&amp;rdquo; and I just didn&amp;rsquo;t want this. (Later, I figured out that remote layouts can be overridden, but that was too late so I already switched to another theme supplier&amp;hellip;)&#10;The theme I&amp;rsquo;m currently using the Minimal Mistakes Theme (see site footer) which is very well documented. After downloading and a couple of first steps, I struggled with custom colors (_sass) and the collections - again - as I didn&amp;rsquo;t understand at first how Jenkill would do the bindings behind the scenes. Some of my struggles can be found in the Blog posts from June 2021.&lt;/p&gt;&#10;&lt;h3 id="site-layouts-hero-screens"&gt;Site layouts: hero screens&lt;/h3&gt;&#10;&lt;p&gt;A bit of thinking should go into how the site layout should be like, to make navigation easy and intuitive. The approach will differ with every page type due to differences in complexity and document structure. &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/thissite/hero_landing.jpg" alt="landing hero"&gt;&lt;/figure&gt;Take the landing / home page for example, I want the navigation to be sticky so that the site&amp;rsquo;s categories are always visible. It shall receive a huge, wide overlay image with the page description. Below, latest posts will be listed.&#10;This is the example for the collection&amp;rsquo;s page hero screen: &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/thissite/hero_collection.jpg" alt="Image: collection hero"&gt;&lt;/figure&gt;&#10;The projects and their contents shall contain a page navigation in the left sidebar, may contain a scrollable header image, and has a wide setting to display a high amount of text in the content area. &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/thissite/hero_project.jpg" alt="Image: project hero"&gt;&lt;/figure&gt;&lt;/p&gt;&#10;&lt;p&gt;The &amp;ldquo;hero screen&amp;rdquo; scetches for this site&amp;rsquo;s layouts can be found &lt;a href="https://blog.schallbert.de/assets/docs/site_hero_screens.pdf"&gt;here&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="collections"&gt;Collections&lt;/h3&gt;&#10;&lt;p&gt;Collections are a great way for grouping content. Look at my &lt;a href="https://blog.schallbert.de/en/projects/"&gt;Open Hardware Projects&lt;/a&gt; page. You can find all projects in this category with a little teaser image and description here to get an overview.&lt;/p&gt;&#10;&lt;p&gt;This is how the collection&amp;rsquo;s configuration can work out:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Think of a fitting name for your collection, e.g. &lt;code&gt;electronics&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Collection name is defined in &lt;code&gt;_config.yml&lt;/code&gt; as &lt;code&gt;collections: electronics&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Collection name has to be set in &lt;code&gt;#defaults&lt;/code&gt; as &lt;code&gt;type: electronics&lt;/code&gt; so that Jekyll knows how to interpret the actual content.&lt;/li&gt;&#10;&lt;li&gt;Collection page has to be created (e.g. in the &lt;code&gt;_pages&lt;/code&gt; folder) with the attribute &lt;code&gt;collection: electronics&lt;/code&gt;. This is the page that will actually display the overview of items in the collection.&lt;/li&gt;&#10;&lt;li&gt;Content folder with the same name as the collection&amp;rsquo;s name with leading underscore has to be created: &lt;code&gt;_electronics&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;All documents in this content folder will show up in the collection page automatically after a full build.&lt;/li&gt;&#10;&lt;li&gt;These documents should get a &amp;ldquo;front matter&amp;rdquo; (section before the actual content in the file, marked with &lt;code&gt;---&lt;/code&gt;) so that they can be displayed as described in the corresponding &lt;code&gt;_layouts&lt;/code&gt; file, e.g. &lt;code&gt;collection&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="more-than-the-standard-theme"&gt;More than the standard Theme&lt;/h2&gt;&#10;&lt;p&gt;I have overridden some theme default and I&amp;rsquo;m using most of the existing Theme infrastructure. But my site has a couple of special needs so I had to customize and add Theme features. These are described in the following sections.&lt;/p&gt;&#10;&lt;h3 id="navigation"&gt;Sidebar: site navigation&lt;/h3&gt;&#10;&lt;p&gt;As my site navigation tree is pretty flat and I don&amp;rsquo;t have lots of pages to navigate to, I wanted the left sidebar to hold the page navigation rather than the site navigation. Thus, I wouldn&amp;rsquo;t need a right sidebar at all, so that there would be more space for the page content.&lt;/p&gt;&#10;&lt;h3 id="table-of-contents-on-the-left"&gt;Table Of Contents on the left&lt;/h3&gt;&#10;&lt;p&gt;As written &lt;a href="https://blog.schallbert.de/en/jekyll-toc/"&gt;here&lt;/a&gt; and &lt;a href="https://blog.schallbert.de/en/jekyll-toc/"&gt;there&lt;/a&gt;, I never was 100% happy with the way my project pages looked like: &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2021-06-17_before_tocleft.jpg" alt="Image: project page before I setup toc_left"&gt;&lt;/figure&gt;&#10;The original idea of the theme is that the content is framed by two sidebars: The left one can either display Author information, custom content, or the site navigation. The content area is configurable between wide and normal, in the latter case leaving space for a second sidebar on the right. The right sidebar is configurable to not be there at all or to show the page&amp;rsquo;s table of contents.&lt;/p&gt;&#10;&lt;p&gt;So the target I set was to eliminate the right sidebar for my design, and instead of having the site navigation in the left sidebar, move the page navigation there.&lt;/p&gt;&#10;&lt;h3 id="sidebar"&gt;Where the sidebar design is defined&lt;/h3&gt;&#10;&lt;p&gt;It&amp;rsquo;s all about the &lt;code&gt;/_layouts&lt;/code&gt; folder that keeps information about how a page layout should look like. The page design in question is called &lt;code&gt;single.html&lt;/code&gt; and it at some point calls&#10;&lt;code&gt;include sidebar.html&lt;/code&gt;&#10;which resides in &lt;code&gt;/_includes/sidebar.html&lt;/code&gt;. So I thought of a new value I could use to control showing the table of contents within the left sidebar, chose &lt;code&gt;toc_left&lt;/code&gt; and added a condition:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-html" data-lang="html"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;div&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;sidebar sticky&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;&amp;lt;!-- ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{.% if page.toc_left %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt; {.% include toc_left nav=page.toc_left %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{.% endif %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;... --&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;div&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;(By the way: I had to comment-out and add &lt;code&gt;.&lt;/code&gt;&amp;rsquo;s to the &lt;a href="https://en.wikipedia.org/wiki/Jinja_%28template_engine%29" target="_blank" rel="noopener noreferrer" class="external-link"&gt;jinja&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; code, &lt;a href="https://jekyllrb.com/docs/liquid/" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Liquid&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; would otherwise interpret these as commands!)&lt;/p&gt;&#10;&lt;h3 id="implementing-the-table-of-contents"&gt;Implementing the Table Of Contents&lt;/h3&gt;&#10;&lt;p&gt;I wanted it to show up in the left sidebar container. So I just had to create a &lt;strong&gt;toc_left&lt;/strong&gt; file that would call the included toc generator like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-html" data-lang="html"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;aside&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;toc_left&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;nav&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;toc&amp;#34;&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;markdown&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;&lt;span style="color:#f92672"&gt;header&lt;/span&gt;&amp;gt;&amp;lt;&lt;span style="color:#f92672"&gt;h4&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;nav__title&amp;#34;&lt;/span&gt;&amp;gt;&amp;lt;&lt;span style="color:#f92672"&gt;i&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;class&lt;/span&gt;&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;fas fa-{{ include.icon | default: &amp;#39;file-alt&amp;#39; }}&amp;#34;&lt;/span&gt;&amp;gt;&amp;lt;/&lt;span style="color:#f92672"&gt;i&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;&amp;lt;!-- ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{.{ include.title | .default: site.data.ui-text[site.locale].toc_label }}&amp;lt;/h4&amp;gt;&amp;lt;/header&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;{.% include toc.html sanitize=true html=content h_min=1 h_max=6 class=&amp;#34;toc__menu&amp;#34; skip_no_ids=true %} &#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;... --&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;nav&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&amp;lt;/&lt;span style="color:#f92672"&gt;aside&lt;/span&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="audio-embeds"&gt;Audio Embeds&lt;/h3&gt;&#10;&lt;p&gt;Audio embeds are these little player-like things on a website, often forwarding to a streaming platform or similar. I need them for some of my audio projects like this &lt;a href="https://blog.schallbert.de/en/projects/mobfobamp/"&gt;mobfobamp&lt;/a&gt;: just a caption and the player below. &lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/embed-audio.jpg" alt="Image: embed-audio"&gt;&lt;/figure&gt;&#10;&lt;a href="https://stackoverflow.com/a/63807971/13757172" target="_blank" rel="noopener noreferrer" class="external-link"&gt;This post&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; helped me a lot, so I could just add it to the &lt;code&gt;_include&lt;/code&gt;, modify it a bit, and it worked!&lt;/p&gt;&#10;&lt;h3 id="favicon"&gt;Placing the favicon&lt;/h3&gt;&#10;&lt;p&gt;I followed &lt;a href="https://ptc-it.de/add-favicon-to-mm-jekyll-site" target="_blank" rel="noopener noreferrer" class="external-link"&gt;this guide&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to place a favicon for the site and it worked flawlessly. &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/posts/2021-06-13_favicon.jpg" alt="Image: favicon"&gt;&lt;/figure&gt;&#10;The favicon&amp;rsquo;s path has to be valid independently of the folder the rendered page resides in. I had to add &lt;code&gt;../&lt;/code&gt; according to the pages&amp;rsquo; depth within the site&amp;rsquo;s folder structure to make sure that the favicon could be displayed not only on the &amp;ldquo;home&amp;rdquo; page, but also from sub-paSges.&lt;/p&gt;&#10;&lt;h3 id="optimizing-for-search-engines"&gt;Optimizing for search engines&lt;/h3&gt;&#10;&lt;p&gt;As my site was not found by search engines for a couple of weeks, I decided to perform so-called &amp;ldquo;SEO&amp;rdquo; (search engine optimization). That&amp;rsquo;s why I added &lt;a href="http://jekyll.github.io/jekyll-seo-tag" target="_blank" rel="noopener noreferrer" class="external-link"&gt;jekyll-seo-tag&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and e.g. reworked all my links following &lt;a href="https://jsinibardy.com/optimize-seo-jekyll#seo-101" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Juliette Sinibardy&amp;rsquo;s SEO 101&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. When complete, I put my webpage through Lighthouse&amp;rsquo;s web-analysis audit. &lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/thissite/lighthouse_audit.jpg" alt="Image: Lighthouse Audit results"&gt;&lt;/figure&gt;&#10;For a first try, I think it looks pretty well. Biggest issue on the performance side seems to be CSS overhead.&lt;/p&gt;&#10;&lt;h3 id="content-security-policy"&gt;Content Security Policy&lt;/h3&gt;&#10;&lt;p&gt;The Lighthouse audit revealed that it would be a good idea to restrict external scripting to my site, so I introduced a Content Security Policy (CSP) like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;webrick&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;headers&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;Content-Security-Policy&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;default-src &amp;#39;self&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;script-src &amp;#39;self&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;connect-src &amp;#39;self&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;img-src &amp;#39;self&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;style-src &amp;#39;self&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;font-src &amp;#39;self&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Initially, unfortunately, my home page&amp;rsquo;s backup image wouldn&amp;rsquo;t show, my embed-audio files wouldn&amp;rsquo;t play and all icons loaded externally were gone as well. Adding &lt;code&gt;media-src: 'self';&lt;/code&gt; and some more reading in &lt;a href="https://www.jakobwillforss.com/post/content-security-policy-for-font-awesome-on-netlify" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Jakob Wilforss&amp;rsquo; blog&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and especially &lt;a href="https://web.dev/strict-csp/?utm_source=lighthouse&amp;amp;utm_medium=lr" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Lighthouse&amp;rsquo;s recommendation&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; helped to solve this. By using the Browser&amp;rsquo;s &lt;em&gt;Web developer Tools&lt;/em&gt;, I figured out the missing resources the theme is loading in the background, so my updated CSP looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;webrick&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;headers&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;Content-Security-Policy&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;default-src &amp;#39;self&amp;#39; &amp;#39;unsafe-inline&amp;#39; https://cdn.jsdelivr.net/; &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;script-src &amp;#39;self&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;connect-src &amp;#39;self&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;img-src &amp;#39;self&amp;#39; &amp;#39;unsafe-inline&amp;#39; https://upload.wikimedia.org/wikipedia/commons/ https://raw.githubusercontent.com;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;style-src &amp;#39;self&amp;#39; &amp;#39;unsafe-inline&amp;#39; https://cdn.jsdelivr.net/;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;font-src &amp;#39;self&amp;#39; &amp;#39;unsafe-inline&amp;#39; https://cdn.jsdelivr.net/; &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;media-src &amp;#39;self&amp;#39;; &lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;object-src &amp;#39;none&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ae81ff"&gt;base-uri &amp;#39;none&amp;#39;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; Update: No more third parties&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2021-12-05T00:00:00Z"&gt;&#10; 2021-12-05&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; By the end of 2021, I chose to not have the page user download external resources from Wikimedia or FontAwesome, but decided to serve that content locally. This way, there&amp;rsquo;s no third party anymore registering calls to their content - plus, my page load times drop a little. I was able to revert changes to my Content Security Policy to the first example (but added &lt;code&gt;media-src 'self';&lt;/code&gt;) above without side effects.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h2 id="errors"&gt;Errors&lt;/h2&gt;&#10;&lt;p&gt;I&amp;rsquo;m collecting some simple errors here that cost me quite some time fixing.&lt;/p&gt;&#10;&lt;h3 id="jekyll-serve-utf-8-incompatible-character-encoding"&gt;Jekyll serve: &lt;code&gt;UTF-8&lt;/code&gt; incompatible character encoding&lt;/h3&gt;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://blog.schallbert.de/assets/images/thissite/bundle_error.jpg" alt="Image: bundle error when front matter is missing"&gt;&lt;/figure&gt;&#10;You enter &lt;code&gt;bundle exec jekyll serve&lt;/code&gt; and get a strange &lt;code&gt;UTF-8&lt;/code&gt; &amp;ldquo;incompatible character encoding&amp;rdquo; error? The cause is simple: One of your files most likely has no header that can be converted into the front matter of the page that is generated from that file. Just add your usual header to your file like so and it will just work:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;title&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;Feeds and Speeds database&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;description&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;for (light) CNC milling machines&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;header&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;image&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;blah/testimage.jpg&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#ae81ff"&gt;...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="webpage-is-not-being-displayed-at-all-404"&gt;Webpage is not being displayed at all: &lt;code&gt;404&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;This error was very, very hidden. I found it by accident browsing my site online only, as &lt;code&gt;localhost&lt;/code&gt; serve was not affected. The symptom is a no-show blank page.&lt;/p&gt;&#10;&lt;p&gt;Cause: Due to translation, my paths are getting longer and I wanted subpages to not have extremely long links. That&amp;rsquo;s why I was using the &lt;strong&gt;permalink:&lt;/strong&gt; Attribute of the pages&amp;rsquo; fontmatter. Now, only if you have a &amp;ldquo;trailing slash&amp;rdquo; here, the page will display on the remote server.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Wrong:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;title&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;This Page won&amp;#39;t be displayed&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;permalink&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;/projects/pageerror&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yml" data-lang="yml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Correct:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;title&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;This Page will be displayed!&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;permalink&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;/projects/nopageerror/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# [...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Unfolds on the english blog to:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# /en/projects/nopageerror/&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;---&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="site-hosting"&gt;Site hosting&lt;/h2&gt;&#10;&lt;p&gt;This site itself is a normal Gitea repository. It is built automatically with Gitea Actions once changes are added to the repository&amp;rsquo;s main branch.&lt;/p&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; From Github Pages to Gitea &amp;#43; Caddy&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2024-01-13T00:00:00Z"&gt;&#10; 2024-01-13&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; I &lt;a href="https://blog.schallbert.de/en/projects/move-blog-to-own-server/"&gt;moved my site&lt;/a&gt; from Github Pages to Gitea. I&amp;rsquo;m self-hosting both Gitea and the site now. Still, the site content is available as a repository and I can easily update and maintain it where I deem fit.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;&lt;h2 id="summary"&gt;Summary&lt;/h2&gt;&#10;&lt;p&gt;To get familiar with how Jekyll creates the site, how the theme would interact with my commands and how to override Theme behavior cost quite a couple of hours, but I think this site has a clear structure and a more or less &amp;ldquo;professional&amp;rdquo; look - And I didn&amp;rsquo;t have to write a single line of JavaScript or HTML to get this done. Nice!&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Contents written in markdown &lt;code&gt;*.md&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Basic site configuration in YAML &lt;code&gt;_config.yml&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Page layouts written in html and jinja &lt;code&gt;*.html&lt;/code&gt;, placed in &lt;code&gt;_layouts&lt;/code&gt; and &lt;code&gt;_includes&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Page variables and appearance in SASS &lt;code&gt;*.scss&lt;/code&gt;, automatically generating css files&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="contribute"&gt;Contribute?&lt;/h2&gt;&#10;&lt;p&gt;You have ideas, criticism, or interesting stuff you&amp;rsquo;d like to let me know? Get started on my &lt;a href="https://github.com/Schallbert/schallbert.github.io/discussions" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Discussions&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; page!&lt;/p&gt;&#10;&lt;aside class="update-box update-box--note" role="note"&gt;&#10; &lt;span class="update-box__icon" aria-hidden="true"&gt;&#10; ℹ️&#10; &lt;/span&gt;&#10;&#10; &lt;div class="update-box__body"&gt;&#10; &lt;div class="update-box__heading"&gt;&#10; &lt;strong class="update-box__title"&gt;&#10; &#10; From Jekyll to Hugo&#10; &#10; &lt;/strong&gt;&#10;&#10; &lt;time datetime="2026-09-06T00:00:00Z"&gt;&#10; 2026-09-06&#10; &lt;/time&gt;&#10; &#10; &lt;/div&gt;&#10;&#10; &#10; &lt;div class="update-box__content"&gt;&#10; In the summer of 2026, I migrated this website to a &lt;a href="https://blog.schallbert.de/en/announcements/2026-09-06-blog-anniversary-hugo/"&gt;new technical platform&lt;/a&gt;. I now use the Hugo site generator. An overview of the changes can be found in my &lt;a href="https://blog.schallbert.de/en/projects/migrating-jekyll-to-hugo/"&gt;project on the migration&lt;/a&gt;.&#10; &lt;/div&gt;&#10; &#10; &lt;/div&gt;&#10;&lt;/aside&gt;&#10;</description></item><item><title>Tonuino - alternative Firmware</title><link>https://blog.schallbert.de/en/projects/tonuino/</link><pubDate>Mon, 01 Jan 0001</pubDate><author>Schallbert</author><guid>https://blog.schallbert.de/en/projects/tonuino/</guid><description type="html">&#10; &lt;img src="https://blog.schallbert.de/assets/images/tonuino/tonuino-thumb.jpg"&#10; class="post-cover"&#10; alt="Tonuino as a Bird&amp;#39;s house"&#10; title="Tonuino - alternative Firmware" /&gt;&#10;&lt;h2 id="project-stats"&gt;Project stats&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Difficulty: hard 5/5&lt;/li&gt;&#10;&lt;li&gt;Cost: 0€&lt;/li&gt;&#10;&lt;li&gt;Time: ~400h&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="impressions"&gt;Impressions&lt;/h2&gt;&#10;&lt;section class="hugo-gallery"&gt;&#10; &lt;div class="hugo-gallery__frame"&gt;&#10; &lt;div class="hugo-gallery__grid" role="list"&gt;&lt;a&#10; class="hugo-gallery__thumb"&#10; href="#gallery-full-0"&#10; aria-label="Image: NFC tags have been woven into little birds made of felt. They can be positioned inside the bird&amp;#39;s house to play music."&gt;&#10; &lt;img&#10; src="https://blog.schallbert.de/assets/images/tonuino/birdshouse_front.jpg"&#10; alt="Image: NFC tags have been woven into little birds made of felt. They can be positioned inside the bird&amp;#39;s house to play music."&#10; class="hugo-gallery__thumb-img"&#10; loading="lazy"&gt;&#10; &lt;/a&gt;&lt;a&#10; class="hugo-gallery__thumb"&#10; href="#gallery-full-1"&#10; aria-label="Image: birdshouse opened. Its contents: An RFID reader, MP3 player, speaker, charger socket, a powerbank, one multicolor-LED and the clickEncoder as human-machine interface."&gt;&#10; &lt;img&#10; src="https://blog.schallbert.de/assets/images/tonuino/birdshouse_inside.jpg"&#10; alt="Image: birdshouse opened. Its contents: An RFID reader, MP3 player, speaker, charger socket, a powerbank, one multicolor-LED and the clickEncoder as human-machine interface."&#10; class="hugo-gallery__thumb-img"&#10; loading="lazy"&gt;&#10; &lt;/a&gt;&lt;a&#10; class="hugo-gallery__thumb"&#10; href="#gallery-full-2"&#10; aria-label="Image: Guitar Amp design: All components are built in an old guitar amp model. Its battery, amplifier and speaker have been reused for Tonuino, and some buttons have been added to control the mp3 player. Cards are placed on top of the amp to be read and select content to be played."&gt;&#10; &lt;img&#10; src="https://blog.schallbert.de/assets/images/tonuino/guitaramp_top.jpg"&#10; alt="Image: Guitar Amp design: All components are built in an old guitar amp model. Its battery, amplifier and speaker have been reused for Tonuino, and some buttons have been added to control the mp3 player. Cards are placed on top of the amp to be read and select content to be played."&#10; class="hugo-gallery__thumb-img"&#10; loading="lazy"&gt;&#10; &lt;/a&gt;&lt;a&#10; class="hugo-gallery__thumb"&#10; href="#gallery-full-3"&#10; aria-label="Image: insides of the guitar amp: An Arduino nano controls all functions. A Bistable relay keeps the system alive as long as it plays music and switches off automatically when done to save battery power."&gt;&#10; &lt;img&#10; src="https://blog.schallbert.de/assets/images/tonuino/guitaramp_inside.jpg"&#10; alt="Image: insides of the guitar amp: An Arduino nano controls all functions. A Bistable relay keeps the system alive as long as it plays music and switches off automatically when done to save battery power."&#10; class="hugo-gallery__thumb-img"&#10; loading="lazy"&gt;&#10; &lt;/a&gt;&lt;/div&gt;&lt;div class="hugo-gallery__caption"&gt;&#10; My alternative tonuino-Firmware in action.&#10; &lt;/div&gt;&lt;/div&gt;&#10;&#10; &lt;div class="hugo-gallery__full" aria-live="polite"&gt;&lt;figure&#10; id="gallery-full-0"&#10; class="hugo-gallery__figure media-frame media-frame--center"&gt;&#10; &lt;img&#10; src="https://blog.schallbert.de/assets/images/tonuino/birdshouse_front.jpg"&#10; alt="Image: NFC tags have been woven into little birds made of felt. They can be positioned inside the bird&amp;#39;s house to play music."&gt;&lt;figcaption&gt;NFC-Tags are birds that can be placed in the bird&amp;#39;s house to play an album.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure&#10; id="gallery-full-1"&#10; class="hugo-gallery__figure media-frame media-frame--center"&gt;&#10; &lt;img&#10; src="https://blog.schallbert.de/assets/images/tonuino/birdshouse_inside.jpg"&#10; alt="Image: birdshouse opened. Its contents: An RFID reader, MP3 player, speaker, charger socket, a powerbank, one multicolor-LED and the clickEncoder as human-machine interface."&gt;&lt;figcaption&gt;A microcontroller implements my encoder library to run this buttonless design of Tonuino.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure&#10; id="gallery-full-2"&#10; class="hugo-gallery__figure media-frame media-frame--center"&gt;&#10; &lt;img&#10; src="https://blog.schallbert.de/assets/images/tonuino/guitaramp_top.jpg"&#10; alt="Image: Guitar Amp design: All components are built in an old guitar amp model. Its battery, amplifier and speaker have been reused for Tonuino, and some buttons have been added to control the mp3 player. Cards are placed on top of the amp to be read and select content to be played."&gt;&lt;figcaption&gt;Tonuino in an old battery-powered guitar amp.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure&#10; id="gallery-full-3"&#10; class="hugo-gallery__figure media-frame media-frame--center"&gt;&#10; &lt;img&#10; src="https://blog.schallbert.de/assets/images/tonuino/guitaramp_inside.jpg"&#10; alt="Image: insides of the guitar amp: An Arduino nano controls all functions. A Bistable relay keeps the system alive as long as it plays music and switches off automatically when done to save battery power."&gt;&lt;figcaption&gt;The player is controlled via three buttons that I took from an old computer keyboard.&lt;/figcaption&gt;&lt;/figure&gt;&lt;/div&gt;&#10;&lt;/section&gt;&#10;&#10;&lt;p&gt;&lt;figure class="media-frame media-frame--right"&gt;&#10; &lt;img src="https://raw.githubusercontent.com/isocpp/logos/64ef037049f87ac74875dbe72695e59118b52186/cpp_logo.svg" alt="Image: C&amp;#43;&amp;#43; logo"&gt;&lt;/figure&gt;&#10;A software written for the Arduino Framework that controls an Mp3 Player (with SD card), an NFC Tag Reader, and provides user controls that together work like a jukebox that can be controlled via NFC Tag.&#10;For example, an NFC tag can be linked to a folder on the SD card containing audio files. The tag will also store information about the requested play mode for this album, e.g. &amp;ldquo;Random&amp;rdquo;. Once a linked tag is placed on this jukebox, the configured folder will play with the selected playmode.&#10;User controls allow to select the track, change volume and even to navigate a voice menu that allows deleting a tag, linking/ configuring a tag, and to lock/ unlock the input keys if required.&lt;/p&gt;&#10;&lt;h2 id="motivation"&gt;Motivation&lt;/h2&gt;&#10;&lt;p&gt;Idea and fantastic execution found on &lt;a href="https://www.voss.earth/tonuino" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Thorsten Voß&amp;rsquo;s blog&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;. It began with a feature (&lt;a href="https://blog.schallbert.de/en/projects/encoder/"&gt;encoder support&lt;/a&gt;) that I wanted to add. Unfortunately, the original code was a single-file application with several thousand lines of code and poor readabilty, so I had a hard time getting my changes in. So I re-wrote the code from scratch for better structure, readability, maintainability, and extensibility. By doing this, in parallel I read some books about Object Oriented design in C++, &lt;a href="https://en.wikipedia.org/wiki/Robert_C._Martin" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Clean Code&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and &lt;a href="https://en.wikipedia.org/wiki/Software_design_pattern" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Software Design Patterns&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; and applied some where I tought they&amp;rsquo;d fit in. This way, I was able to both improve modularity and readability of the code, plus I taught myself how to write code in a &amp;ldquo;bigger&amp;rdquo; project.&lt;/p&gt;&#10;&lt;h2 id="hurdles-to-overcome"&gt;Hurdles to overcome&lt;/h2&gt;&#10;&lt;p&gt;This was first real OO C++ project, and, maybe, it was a little big to start with. All the new-to-me concepts, writing to an interface, first-time usage of &lt;a href="https://platformio.org" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Platformio as IDE&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, utilizing the &lt;a href="https://google.github.io/googletest" target="_blank" rel="noopener noreferrer" class="external-link"&gt;googletest unit test framework&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;, using coding patterns like &lt;a href="https://www.oodesign.com/factory-pattern.html" target="_blank" rel="noopener noreferrer" class="external-link"&gt;factory&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; or &lt;a href="https://en.wikipedia.org/wiki/Dependency_injection" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Dependency Injection&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; took nearly a year of evenings before this project could be completed. And there&amp;rsquo;s always something left to refactor and to improve.&lt;/p&gt;&#10;&lt;h2 id="quick-facts"&gt;Quick facts&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Unit test suite of 250+ test cases&lt;/li&gt;&#10;&lt;li&gt;Serial debug output configurable&lt;/li&gt;&#10;&lt;li&gt;Loosely coupled C++ OO architecture&lt;/li&gt;&#10;&lt;li&gt;Individual modules with clear tasks, scaleable and easily accessible for future feature add&lt;/li&gt;&#10;&lt;li&gt;custom Hardware abstraction layer; most of the code should be portable to other mcus without changes&lt;/li&gt;&#10;&lt;li&gt;custom Dependency Injection framework (Loader class)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="features"&gt;Features&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Configurable user input (buttons or encoder)&lt;/li&gt;&#10;&lt;li&gt;Auto-Poweroff when no button pressed for a configurable time&lt;/li&gt;&#10;&lt;li&gt;Power on via &amp;ldquo;play&amp;rdquo; button press&lt;/li&gt;&#10;&lt;li&gt;Autoplay feature&lt;/li&gt;&#10;&lt;li&gt;Status Led feature&lt;/li&gt;&#10;&lt;li&gt;User Input lock/unlock feature&lt;/li&gt;&#10;&lt;li&gt;Multiple playmodes [Album, Random, SaveProgress, OneTrack] available per Nfc Tag&lt;/li&gt;&#10;&lt;li&gt;Voice menus for linking or deletion of Nfc Tags&lt;/li&gt;&#10;&lt;li&gt;Optimized for battery applications (e.g. powerbank) using sleep states&lt;/li&gt;&#10;&lt;li&gt;Low power consumption @5V: ~40mA in idle, ~75mA playing medium volume&lt;/li&gt;&#10;&lt;li&gt;Config file for init volume, lullabye timer etc.&lt;/li&gt;&#10;&lt;li&gt;Auto-recovery from stuck prompts&lt;/li&gt;&#10;&lt;li&gt;Voice prompts for most common state errors&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="features-not-incliuded"&gt;Features not incliuded&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;No powerbank state of charge detection&lt;/li&gt;&#10;&lt;li&gt;No config settings menu (init volume, lullabye timer duration, standby duration etc.)&lt;/li&gt;&#10;&lt;li&gt;&amp;hellip;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="documentation"&gt;Documentation&lt;/h2&gt;&#10;&lt;p&gt;The Project is build for the Arduino Framework - tested on an Arduino nano board - using the &lt;a href="https://platformio.org" target="_blank" rel="noopener noreferrer" class="external-link"&gt;PlatformIO IDE&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;.&#10;The following sections show the design.&lt;/p&gt;&#10;&lt;h3 id="project-module-overview"&gt;Project Module overview&lt;/h3&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Folder name in &lt;code&gt;/lib&lt;/code&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Purpose&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Arduino&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;minimalistic close-to-Hardware implementations, not unit-testable&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Arduino_HardwareAbstraction&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Hardware abstraction to enable portability and testing&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Config&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;System configuration parameters&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Folder&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Playlist and playmode business logic&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Loader&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Dependency Injection Framework&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;MessageHandler&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;System messages and Debug Framework&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mp3&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mp3 control (Status, Folder, Prompts, Advertisements)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Nfc&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tag control (Status, Read, Write, Delete)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;PowerManager&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Control Status Led and Keep Alive based on system state&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Tonuino&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Main task scheduler&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;UserInput&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Button or Encoder input handling&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Utilities&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Timers, Led Control, Pin control&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;VoiceMenu&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Link / Delete / Config menu business logic&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h3 id="external-libraries"&gt;External Libraries&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://platformio.org/lib/show/131/TimerOne" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Arduino&amp;rsquo;s TimerOne&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://platformio.org/lib/show/7212/SoftwareSerial" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Arduino&amp;rsquo;s SoftwareSerial&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://platformio.org/lib/show/11808/ClickEncoder" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Schallbert&amp;rsquo;s ClickEncoder&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://platformio.org/lib/show/1561/DFPlayer%20Mini%20Mp3%20by%20Makuna" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Makuna&amp;rsquo;s DFMini Mp3&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://platformio.org/lib/show/63/MFRC522" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Miguel Balboa&amp;rsquo;s MFRC522&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Auto-install through Platformio on initial build through Library Dependency Finder.&lt;/p&gt;&#10;&lt;h3 id="class-diagrams"&gt;Class Diagrams&lt;/h3&gt;&#10;&lt;p&gt;Class diagrams would have been too much non-automated work. Take this instead, please.&#10;&lt;a href="https://raw.githubusercontent.com/Schallbert/Tonuino_alternativeFirmware/master/docs/ProjectModulesOverview.png" target="_blank" rel="noopener noreferrer" class="external-link"&gt;Software module overview&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt;&#10;It should give an accurate understanding of how the software module are interacting and what APIs the modules are offering.&lt;/p&gt;&#10;&lt;h2 id="get-started"&gt;Get started!&lt;/h2&gt;&#10;&lt;p&gt;It&amp;rsquo;s DIY time!&#10;Clone my &lt;a href="https://github.com/Schallbert/Tonuino_alternativeFirmware" target="_blank" rel="noopener noreferrer" class="external-link"&gt;repository&lt;span class="external-link-icon" aria-hidden="true"&gt;↗&lt;/span&gt;&lt;/a&gt; to get started with. Read the &lt;code&gt;README.md&lt;/code&gt; document. Build it with PlatformIO.&#10;Below you can find what you need to get started with your unique speaker design.&lt;/p&gt;&#10;&lt;h2 id="testing"&gt;Testing&lt;/h2&gt;&#10;&lt;h3 id="unit-tests"&gt;Unit tests&lt;/h3&gt;&#10;&lt;p&gt;Unit tests are written with the gtest C++ unit test framework. The reside in the &lt;code&gt;test/desktop&lt;/code&gt; folder. Note that &lt;em&gt;googletest&lt;/em&gt; will require &lt;code&gt;gcc&lt;/code&gt; with some libraries to be installed, how to can be found &lt;a href="https://blog.schallbert.de/en/projects/platformio_gtestgmock/"&gt;in one of my other projects&lt;/a&gt;.&#10;Once these prerequisites are complete and a simple &lt;code&gt;ASSERT_TRUE(false);&lt;/code&gt; test fails as expected using the gtest environment, the project&amp;rsquo;s Unit Tests can be built and run using &lt;code&gt;pio test -e desktop -f desktop&lt;/code&gt;, &lt;a href="https://blog.schallbert.de/en/projects/platformio_gtestgmock/#writing-tests"&gt;detailed out here&lt;/a&gt;, in the PlatformIO CLI (terminal).&lt;/p&gt;&#10;&lt;h3 id="acceptance-tests"&gt;Acceptance tests&lt;/h3&gt;&#10;&lt;p&gt;Although these tests could be automated, it is much easier to just perform those tests by hand after you programmed your µC and put all electronic components together.&#10;Each line in the tables below is a test case on its own. The test suites (Heading name) do have a &lt;strong&gt;System Pre:&lt;/strong&gt; property, that needs to be re-established prior to executing the individual test cases. If the expectation clause is met, the test is a &lt;strong&gt;PASS&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;h4 id="switching-on"&gt;Switching ON&lt;/h4&gt;&#10;&lt;p&gt;System Pre: System is OFF&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Action&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expectation&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;LED flashes slowly?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Play/pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Welcome prompt plays?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h4 id="switching-off"&gt;Switching OFF&lt;/h4&gt;&#10;&lt;p&gt;System Pre: System is ON&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Action&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expectation&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Playback on pause, no button input&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;System switches off after a time?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;System shutdown&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Farewell prompt plays?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;System shutdown&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;LED switched off?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h4 id="play-help-prompt"&gt;Play Help Prompt&lt;/h4&gt;&#10;&lt;p&gt;System Pre: System is ON&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Action&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expectation&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Long press Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Help prompt plays?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Help prompty playing&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Can be interrupted with any button press?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h4 id="behavior-without-tag"&gt;Behavior without Tag&lt;/h4&gt;&#10;&lt;p&gt;System Pre: System is ON, no Tag present&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Action&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expectation&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;prompts &amp;ldquo;couldn&amp;rsquo;t find track&amp;rdquo; error?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Next button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;prompts &amp;ldquo;couldn&amp;rsquo;t find track&amp;rdquo; error?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Prev button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;prompts &amp;ldquo;couldn&amp;rsquo;t find track&amp;rdquo; error?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;doubleclick Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Delete Menu prompt played?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h4 id="behavior-with-tag"&gt;Behavior with Tag&lt;/h4&gt;&#10;&lt;p&gt;System Pre: System is ON, linked Tag available&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Precondition&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Action&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expectation&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;no Tag placed&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;place known Tag&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;starts Playback of correct Folder?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Next button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;plays next track?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Next button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;next track in accordance with selected playMode of Folder?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;long press Next button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;increases volume?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Prev button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;plays previous track?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Prev button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;previous track in accordance with selected playMode of Folder?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;long press Prev button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;decreases volume?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;pauses track?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;paused playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;press Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;resumes track?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;doubleclick Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Lock prompt played?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;doubleclick Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Playback resumes after Lock prompt played?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;active playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;doubleclick Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;locks button input?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;locked button input&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;doubleclick Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Unlock prompt played?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;locked button input&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;doubleclick Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Playback resumes after Unlock prompt played?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;locked button input&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;doubleclick Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;unlocks button input?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;paused playback&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;doubleclick Play/Pause button&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Playback resumes after Lock prompt played?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h4 id="behavior-with-unlinked-tag"&gt;Behavior with unlinked Tag&lt;/h4&gt;&#10;&lt;p&gt;System Pre: System is ON&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Action&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expectation&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;place unlinked Tag&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;plays LinkMenu Prompt?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;navigate through LinkMenu&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;plays Configuration Success Prompt?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;place Tag again&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;plays linked folder?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h2 id="hardware"&gt;Hardware&lt;/h2&gt;&#10;&lt;h3 id="material-list"&gt;Material list&lt;/h3&gt;&#10;&lt;p&gt;Minimal discrete parts approach&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Amount&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Item&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Purpose&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Arduino (e.g. Nano)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Brains&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Dfplayer Mini Mp3 Player&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mouth&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Micro SD Card&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Memory&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;MFRC522 Nfc Reader&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Psi-Sense&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Bistable Relay 5V, e.g. HFE20&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Coffee - no sleep&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Diodes e.g. 1n4007&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;vene valve&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;PNP Transistor e.g. BC327&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Coffee maker switch&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Resistors 1k&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;blood-stream conditioner&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Resistor 220&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;dim blink strength&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;LED, color of choice&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;blink&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Powerbank/ 5V supply&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Food&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;3&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;push buttons, e.g. cherry MX keys&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;pressure sensors&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;speaker, e.g.5W@4Ohms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Lungs&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;USB-A plug&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Straw&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;housing&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Body&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h3 id="additional-coponents-and-tips"&gt;Additional coponents and tips&lt;/h3&gt;&#10;&lt;p&gt;Jumper wires, PCB, Sockets, and expendable materials as you deem fit&#10;Same as for the &amp;ldquo;original&amp;rdquo; project; Alternatively buy one rotary encoder instead of push buttons and configure the project to use it.&#10;As the system is optimized for battery usage, take a bi-stable relay or a JFET transistor (with low gate voltage and low Source-Drain voltage drop) for keepAlive functionality mitigating additional current add through always-on relay coils. A cheap powerbank will last for many hours. Alternatively, e.g. 3xAA batteries can be used - but the Dfmini will really make some noise if not enough current can be supplied due to running resets (and may even get bricked through current surges, so beware).&lt;/p&gt;&#10;&lt;h3 id="schematics"&gt;Schematics&lt;/h3&gt;&#10;&lt;figure class="media-frame media-frame--center"&gt;&#10; &lt;img src="https://raw.githubusercontent.com/Schallbert/Tonuino_alternativeFirmware/master/docs/CircuitDiagram.jpg" alt="Circuit Diagram"&gt;&lt;/figure&gt;&#10;</description></item></channel></rss>