<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[blog - gorala]]></title><description><![CDATA[A blog about software engineering]]></description><link>https://blog.gorala.icu/</link><image><url>https://blog.gorala.icu/favicon.png</url><title>blog - gorala</title><link>https://blog.gorala.icu/</link></image><generator>Ghost 4.48</generator><lastBuildDate>Mon, 12 Jan 2026 11:03:15 GMT</lastBuildDate><atom:link href="https://blog.gorala.icu/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[How I Built Over 100 Programmatic SEO Templates with Python, Hugo, and Local AI]]></title><description><![CDATA[Automate 100+ SEO templates with Python, Hugo & Ollama. We reveal the architecture behind a scalable local search engine, including "Airbnb-style" cards and a fix for Go's printf vs CSS conflict. A practical guide to modern Programmatic SEO.]]></description><link>https://blog.gorala.icu/how-i-built-over-100-programmatic-seo-templates-with-python-hugo-and-local-ai/</link><guid isPermaLink="false">6942e322060b510001bdd93d</guid><category><![CDATA[HTML]]></category><category><![CDATA[Frontend-Development]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Marcel Schepaniak]]></dc:creator><pubDate>Wed, 17 Dec 2025 17:16:12 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1497219055242-93359eeed651?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDE4fHxjcmFmdHxlbnwwfHx8fDE3NjU5OTI3Mjd8MA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1497219055242-93359eeed651?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8c2VhcmNofDE4fHxjcmFmdHxlbnwwfHx8fDE3NjU5OTI3Mjd8MA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="How I Built Over 100 Programmatic SEO Templates with Python, Hugo, and Local AI"><p><strong>Finding a local tradesman is hard. Building a high-performance search engine to solve that shouldn&apos;t be.</strong></p><p>In this article, I&#x2019;ll show you how I built a <strong>Programmatic SEO (pSEO)</strong> project for the city of Bremen. The goal: To generate over <strong>100 high-performance landing page templates</strong>&#x2014;covering every district and every trade (locksmiths, plumbers, electricians)&#x2014;fully automated, blazing fast, and offering real value to the user.</p><p>We will dive deep into the tech stack: From data acquisition with Python to content generation using local LLMs (<strong>Ollama</strong>) and final rendering with Hugo.</p><hr><h2 id="%F0%9F%8F%97%EF%B8%8F-the-architecture">&#x1F3D7;&#xFE0F; The Architecture</h2><p>The problem with classic CMS platforms like WordPress is often performance when dealing with hundreds or thousands of pages. That&#x2019;s why I chose a <strong>Static Site Generator (SSG)</strong>. The site is &quot;built&quot; once and then served as pure HTML.</p><p><strong>The Stack:</strong></p><ul><li><strong>Data Source:</strong> CSV (cleaned and enriched)</li><li><strong>Logic &amp; Generator:</strong> Python (Pandas)</li><li><strong>Content AI:</strong> <strong>Ollama</strong> (running Llama 3 locally)</li><li><strong>Frontend/Rendering:</strong> Hugo (Go-based)</li><li><strong>Hosting:</strong> Static (e.g., Hetzner/Netlify/Vercel)</li></ul><hr><h2 id="step-1-the-data-foundation-python-pandas">Step 1: The Data Foundation (Python &amp; Pandas)</h2><p>Everything stands or falls with the data. I started with a raw CSV file (<code>handwerker_bremen.csv</code>) containing columns like <code>name</code>, <code>address</code>, <code>rating</code>, <code>zip_code</code>, etc.</p><p>The challenge: Data is often &quot;dirty.&quot; Zip codes are missing, special characters are broken, and categories are inconsistent.</p><pre><code>import pandas as pd
import numpy as np

# Load data
df = pd.read_csv(&apos;handwerker_bremen.csv&apos;)

# Mapping for clean district names based on Zip Code
PLZ_TO_DISTRICT = {
    &quot;28195&quot;: &quot;Bremen-Mitte&quot;,
    &quot;28203&quot;: &quot;Steintor / Ostertor&quot;,
    # ... further mappings
}

def get_district_name(plz):
    return PLZ_TO_DISTRICT.get(str(plz), f&quot;Bremen (Zip {plz})&quot;)

# Clean slugs for URLs (e.g., &quot;Locksmith in Bremen Mitte&quot;)
def clean_slug(text):
    text = text.lower()
    replacements = {&apos;&#xE4;&apos;: &apos;ae&apos;, &apos;&#xF6;&apos;: &apos;oe&apos;, &apos;&#xFC;&apos;: &apos;ue&apos;, &apos;&#xDF;&apos;: &apos;ss&apos;, &apos; &apos;: &apos;-&apos;}
    for old, new in replacements.items():
        text = text.replace(old, new)
    return text
</code></pre><p>We then group the data by <strong>Category</strong> and <strong>Zip Code</strong>. Each of these groups serves as the basis for one of our <strong>100+ unique templates</strong>.Python</p><pre><code>grouped = df.groupby([&apos;search_category&apos;, &apos;search_plz&apos;])
print(f&quot;Templates to generate: {len(grouped)}&quot;)
</code></pre><hr><h2 id="step-2-content-generation-with-local-ai-ollama">Step 2: Content Generation with Local AI (Ollama)</h2><p>This is where it gets interesting. Instead of generic placeholder text (&quot;Here you find a plumber&quot;), I wanted unique introductions for every single one of the <strong>100+ templates</strong>. Since API costs can skyrocket with this many pages, I used <strong>Ollama</strong> running <strong>Llama 3</strong> locally on my machine.</p><p>The biggest challenge? Hallucinations and cut-offs.</p><p>Ollama tends to stop sentences in the middle if the num_predict (token limit) is too low, or it starts rambling.</p><p><strong>Our Solution: Prompt Engineering &amp; Fallback Functions</strong></p><pre><code>import requests

def query_ollama(prompt):
    payload = {
        &quot;model&quot;: &quot;llama3&quot;,
        &quot;prompt&quot;: prompt,
        &quot;stream&quot;: False,
        &quot;options&quot;: {
            &quot;temperature&quot;: 0.6, # Less creative, more focused
            &quot;num_predict&quot;: 200, # Enough room to finish the sentence
        }
    }
    response = requests.post(&quot;http://localhost:11434/api/generate&quot;, json=payload)
    return response.json().get(&apos;response&apos;, &apos;&apos;).strip()

# IMPORTANT: Cleanly cut off sentences if the AI stops mid-stream
def clean_incomplete_sentence(text):
    if text.strip().endswith((&apos;.&apos;, &apos;!&apos;, &apos;?&apos;)):
        return text
    
    # Cut everything after the last punctuation mark
    last_punct = max(text.rfind(&apos;.&apos;), text.rfind(&apos;!&apos;), text.rfind(&apos;?&apos;))
    if last_punct != -1:
        return text[:last_punct+1]
    return text
</code></pre><p>The prompt itself assigns the AI a clear role (&quot;Copywriter&quot;) and sets hard constraints (&quot;Max 50-70 words&quot;, &quot;No headlines&quot;).</p><hr><h2 id="step-3-the-hugo-generator">Step 3: The Hugo Generator</h2><p>Python doesn&apos;t generate HTML directly; instead, it creates JSON files that serve as &quot;Data Files&quot; in Hugo. This keeps logic and design cleanly separated.</p><p>The Python script creates a massive <code>bremen_services.json</code> containing the structure for over <strong>100 templates</strong>:JSON</p><pre><code>[
  {
    &quot;page_slug&quot;: &quot;locksmith-bremen-28195&quot;,
    &quot;page_title&quot;: &quot;Top Locksmith in Bremen-Mitte (28195)&quot;,
    &quot;intro_text_ai&quot;: &quot;Door slammed shut? Don&apos;t panic...&quot;,
    &quot;service_list_json&quot;: &quot;[...]&quot; 
  },
  ...
]
</code></pre><p>In Hugo, we use a specific layout (<code>layouts/services/list.html</code>) that iterates over this JSON file and uses <code>resources.FromString</code> to generate virtual HTML pages from these templates.Go</p><pre><code>{{ range $index, $page_data := site.Data.bremen_services }}
    {{ $slug := $page_data.page_slug }}
    {{ $filename := printf &quot;services/%s.html&quot; $slug }}
    
    {{/* HTML Content is assembled here */}}
    {{ $html_content := printf &quot;...&quot; }}
    
    {{ $file := resources.FromString $filename $html_content }}
    {{ $file.Publish }}
{{ end }}
</code></pre><p><strong>Why so complex?</strong> Because Hugo is incredibly fast. We can build these <strong>100+ templates</strong> and pages in a matter of seconds.</p><hr><h2 id="step-4-ux-frontend-the-airbnb-design">Step 4: UX &amp; Frontend (The &quot;Airbnb Design&quot;)</h2><p>Data is useless if it&apos;s presented poorly. Our goal was trust. No one calls a tradesman from a site that looks like it was built in 1998.</p><p>We took inspiration from the <strong>Airbnb Card Design</strong>:</p><ul><li>Plenty of whitespace</li><li>Subtle shadows (<code>box-shadow</code>) that elevate on hover</li><li>Clear &quot;Call to Action&quot; buttons</li><li>&quot;Social Proof&quot; via highlighted ratings</li></ul><p><strong>CSS Snippet for the Cards:</strong></p><pre><code>.service-card { 
    background: #fff; 
    border-radius: 12px; 
    border: 1px solid #ebebeb; 
    transition: all 0.3s cubic-bezier(0.2, 0, 0, 1); 
    display: flex; 
    flex-direction: column; 
}

.service-card:hover { 
    transform: translateY(-4px); 
    box-shadow: 0 12px 20px rgba(0, 0, 0, 0.08); 
    border-color: transparent; 
}

.btn-primary {
    background: #FF385C; /* The typical Airbnb Red */
    color: white;
    font-weight: 700;
}
</code></pre><p>Additionally, we implemented an <strong>FAQ Accordion</strong> which not only helps users (&quot;How much does this cost?&quot;) but also delivers structured data via <strong>JSON-LD Schema</strong> to Google. This massively increases the chance of getting Rich Snippets in search results.</p><hr><h2 id="when-syntax-collides-the-printf-vs-css-battle">When Syntax Collides: The <code>printf</code> vs. CSS Battle</h2><p>One of the most technically demanding aspects of this project wasn&apos;t the AI generation or the data scraping&#x2014;it was a subtle syntax collision between Go templates and CSS that nearly derailed the design implementation.</p><p>In Hugo, when we generate pages dynamically using <code>resources.FromString</code>, we build the entire HTML structure as a giant string inside a <code>printf</code> statement. This allows us to inject variables like <code>$title</code> or <code>$district</code> directly into the code. However, <code>printf</code> in Go uses the percentage symbol (<code>%</code>) as a formatting verb (e.g., <code>%s</code> for strings, <code>%d</code> for integers).</p><p><strong>Here lies the problem:</strong> CSS also relies heavily on the percentage symbol. Whether it&apos;s <code>width: 100%</code>, <code>flex-basis: 50%</code>, or keyframe animations, the <code>%</code> is omnipresent in modern web design.</p><p>When Hugo&apos;s compiler encountered a line like <code>.container { width: 100% }</code> inside our string, it panicked. It tried to interpret the <code>%</code> followed by a closing brace <code>}</code> as a variable placeholder, which doesn&apos;t exist. This resulted in cryptic build errors or, even worse, the styles breaking silently because the CSS string was malformed during the render process.</p><p>The Solution:</p><p>To make this work, we had to implement a strict escaping protocol within our templates. Every single instance of a CSS percentage sign had to be doubled to %.</p><pre><code>/* WRONG - Causes Hugo Build Failure */
{{ $html := printf &quot;&lt;style&gt; .box { width: 100%; } &lt;/style&gt;&quot; }}

/* RIGHT - Escaped for Go Printf */
{{ $html := printf &quot;&lt;style&gt; .box { width: 100%%; } &lt;/style&gt;&quot; }}
</code></pre><p>This seems like a minor detail, but when you are managing hundreds of lines of inline CSS to ensure critical rendering path performance for over 100 templates, &quot;hunting the percent sign&quot; becomes a major part of the debugging process. It taught us a valuable lesson: when mixing languages (Go and CSS) within a single string context, always be aware of reserved characters.</p><h2 id="conclusion">Conclusion</h2><p>With just about 200 lines of Python code and a well-structured Hugo architecture, we created a scalable, low-maintenance portal based on <strong>over 100 templates</strong>. If the data changes (new tradesmen, new prices), we simply run the Python script again, Hugo rebuilds, and the update is live.</p><p>Programmatic SEO is powerful&#x2014;if you prioritize quality over quantity. By using local AI generation via <strong>Ollama</strong> and a high-quality frontend design, this project stands out distinctly from typical &quot;spam directories.&quot;</p><hr><p><strong>Tech Stack:</strong> Python 3.11, Pandas, Ollama (Llama 3), Hugo Extended, HTML5/CSS3.</p>]]></content:encoded></item><item><title><![CDATA[Creating an embeddable HTML snippet with Django and WebComponents]]></title><description><![CDATA[This article aims at creating a basic understanding of how we generated a snippet with dynamic content which is embeddable on any external site. ]]></description><link>https://blog.gorala.icu/creating-an-embeddable-snippet-with-django-and-webcomponents/</link><guid isPermaLink="false">640d1aa882fb730001887d4f</guid><category><![CDATA[Frontend-Development]]></category><category><![CDATA[Backend-Development]]></category><category><![CDATA[Django]]></category><category><![CDATA[Python]]></category><category><![CDATA[HTML]]></category><dc:creator><![CDATA[Marcel Schepaniak]]></dc:creator><pubDate>Sun, 12 Mar 2023 00:20:07 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1635350736475-c8cef4b21906?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwxMTc3M3wwfDF8c2VhcmNofDl8fGpvYnxlbnwwfHx8fDE2NzU2ODU2MDM&amp;ixlib=rb-4.0.3&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1635350736475-c8cef4b21906?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwxMTc3M3wwfDF8c2VhcmNofDl8fGpvYnxlbnwwfHx8fDE2NzU2ODU2MDM&amp;ixlib=rb-4.0.3&amp;q=80&amp;w=2000" alt="Creating an embeddable HTML snippet with Django and WebComponents"><p>This article aims at creating a basic understanding of how we generated a HTML snippet with dynamic content which is embeddable on any external site. <br>A comparable solution is the <a href="https://publish.twitter.com/?query=https%3A%2F%2Ftwitter.com%2FTwitter%2Fstatus%2F1601692766257709056&amp;widget=Tweet">Twitter post embedding functionality</a>. In our case the content of the embeddable snippet is a list of job offering entries which contains an application link.</p><h3 id="our-situation">Our situation</h3><p>We have a plattform written in Python / Django which is responsible for letting the user creating and managing job offers for their company. On our platform are multiple companies who are supposed to manage their offers. There are two specific usecases the snippets are aiming at.</p><ol><li>Generate a HTML snippet of all jobs on the platform</li><li>Generate a HTML snippet of all jobs of one specific company on the platform</li></ol><p>The idea behind those snippets is that they can be embedded on either public pages which list all job offers or for example on specific sites of a company itself (e.g. their own website). So advertisment and distribution of those offers shall primarily happen via HTML snippet embedding. </p><p>The management of all job offers happens via our internal platform. The management process of those offers are merely basic <a href="https://en.wikipedia.org/wiki/Create,_read,_update_and_delete">CRUD </a>operations and are self explanatory from a technical standpoint. A job offer contains basic fields like a <em>title</em>, <em>a description</em>, <em>some tags</em> and information about the company which is responsible for the offer.</p><h3 id="our-approach">Our approach</h3><p>After some research we found out that the common way of doing this is via JavaScript. Basically this is pretty self explanatory if you think about it. The code snipped which takes care of the injection looks like the following:</p><figure class="kg-card kg-code-card"><pre><code class="language-html">&lt;div id=&quot;unique-jobs-id&quot; class=&quot;job-class&quot;&gt;&lt;/div&gt;
&lt;script async src=&quot;https://your-url/jobsapi/v1/widget/&quot; charset=&quot;utf-8&quot;&gt;&lt;/script</code></pre><figcaption>Embeddable HTML part</figcaption></figure><p>This is the part the webmaster of the site who wants to embed the offers has to add to his page. Everything else will be handled by JavaScript.</p><p>We have a <code>div</code> which serves as container for the list we want to embed. Also we have script which is called asynchronously (so that loading of the rest of the site is in no case blocked by our script). The script fetches the HTML code which contains a list of all job offers and then injects it into the <code>div</code> by an id query selector. </p><p>This image should boil down what happens.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.gorala.icu/content/images/2022/12/structure.png" class="kg-image" alt="Creating an embeddable HTML snippet with Django and WebComponents" loading="lazy" width="1280" height="720" srcset="https://blog.gorala.icu/content/images/size/w600/2022/12/structure.png 600w, https://blog.gorala.icu/content/images/size/w1000/2022/12/structure.png 1000w, https://blog.gorala.icu/content/images/2022/12/structure.png 1280w" sizes="(min-width: 720px) 720px"><figcaption>Structure of API, JS and div</figcaption></figure><h3 id="challenges-considerations">Challenges &amp; Considerations</h3><p>In theory the sketched approach from above seems pretty straight forward. How ever I wanted to emphasize on some things we discussed internally as well as challenges we faced during implementing the approach from above.</p><ol><li><strong>HTML/CSS class interference</strong></li></ol><p>What I mean by that is that the retrieved HTML code from our server may interfere which HTML or CSS code which is already applied on the parent site. The snippet CSS code is embedded into the parent site. Imagine two elements having the same class (for instance <code>.job-list</code>). The retrieved CSS from our API would therefore also apply CSS styling to the parent page and therefore could break the layout of the parent page. This should be kept in mind and ideally should not be possible.</p><p>The two easiest solution for that are probably either prefixing every class with a value or using WebComponents with a <a href="https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM">Shadow DOM</a>. If you prefix all classes with a unique value there is still a little chance that there might be an interference. That is why we went for an open Shadow DOM solution. More on that down below. </p><p><strong>2. The HTML Code needs to be generated serverside and be embeddable</strong></p><p>For our solution it is neccessary that we retrieve HTML Code from our server which we just can inject into the target site. With Django this was pretty easy since we just could use server side rendering with a Django partial for that. </p><p>It would also be possible for instance to return JSON data and generate the needed HTML code with JavaSript on the client side. How ever that felt more tedious for us to do so. So we decided to just append the retrieved HTML code with JavaScript. Also it is important that we add as little JavaScript as neccessary to ensure the parent site performance feels well.</p><h3 id="implementation">Implementation</h3><p>On our Django server we have two endpoints:</p><figure class="kg-card kg-code-card"><pre><code class="language-python">urlpatterns = [    
path(&quot;widget/&lt;uuid&gt;/&quot;, job_widget, name=&quot;jobs_api.widget&quot;),   
path(&quot;jobs/&lt;uuid&gt;/&quot;, joblist, name=&quot;jobs_api.joblist&quot;),
]</code></pre><figcaption>URL Endpoints</figcaption></figure><p><br>The <code>uuid</code> part is added for multitenancy support. So in case we want to support two different versions of the JavaScript part or just provide a subset of job offers. <br>The widget endpoint is responsible for delivering the JavaScript part (in our case the JavaScript code for the WebComponent). It looks like the following:</p><figure class="kg-card kg-code-card"><pre><code class="language-python">def job_widget(request, tenant_uuid):    
   
	link = f&quot;{request.scheme}://{request.get_host()}{reverse(&apos;jobs_api.joblist&apos;, args=[tenant_uuid])}&quot;    
	return render(request, &quot;jobs_api/job-widget.js&quot;, {&quot;link&quot;: link, &quot;tenant_uuid&quot;: tenant_uuid}, content_type=&quot;application/javascript&quot;)</code></pre><figcaption>Endpoint which returns JavaScript</figcaption></figure><p>As you can see we are sever side rendering the <code>job-widget.js</code> part instead of just providing it statically. This is for once because of the <code>tenant_uuid</code> for multi tenant support as well as that we are injecting the generated link from above into the script. We do that so that we do not have to hardcode the URL where the JavaScript part fetches the jobs from into the file itself. In case you have a testing or staging system delivery is much easier.</p><p>Furthermore we have the endpoint which returns the JobList as HTML. This ofcourse heavily depends on what you want to return. To keep it simple and understandable we stripped some parts of our code here. The example should still be fully comprehensible.</p><figure class="kg-card kg-code-card"><pre><code class="language-python">def joblist(request, tenant_uuid):

    jobs = []
    filtered_for_company_id = request.GET.get(&quot;c&quot;)

    jobs = Job.objects.filter(tenant__uuid=tenant_uuid)

    if filtered_for_company_id:
        company = get_object_or_404(Company, id=filtered_for_company_id)
        jobs = jobs.filter(published_by__company=company)

    jobs = jobs.order_by(&quot;-published_on&quot;)
    return render(
        request,
        &quot;jobs_api/joblist-snippet.html&quot;,
        {&quot;joblist&quot;: jobs},
        content_type=&quot;text/html&quot;,
    )</code></pre><figcaption>Endpoint which returns the HTML job list</figcaption></figure><p>As you can see we basically return a HTML filled with the job offers. As addition we also check for an URL parameter called <code>c</code> to filter job offers of a specific company. This how ever is really additional and not neccessary.</p><p>The rendered HTML file is a simple Django partial with a foreach loop. To keep this article short and informative I will not go in detail about this. </p><h3 id="the-webcomponent">The WebComponent</h3><p>I also do not want to discuss WebComponents in general. As mentioned before the main benefit for us is the encapsulation from the environment where we want to add our snippet to ensure minimal interference from the parent site. If you want to read about it a good starting point is <a href="https://developer.mozilla.org/en-US/docs/Web/Web_Components">here</a>.</p><p>Lets take a look at the JavaScript part which fetches and embeds the HTML part from above into our component.</p><figure class="kg-card kg-code-card"><pre><code class="language-javascript">const url = &quot;{{link}}&quot;; //Injected from Django
const template = document.createElement(&apos;template&apos;);
class JobSnippet extends HTMLElement {
    constructor() {
        super();
 
        const shadowRoot = this.attachShadow({mode: &apos;open&apos;});
        shadowRoot.appendChild(template.content.cloneNode(true));
    }

    connectedCallback() {
        let shadowHost = this.shadowRoot;
        const btns = shadowHost.querySelectorAll(&apos;.jobs-show-job-details&apos;);

        btns.forEach(element =&gt; element.addEventListener(&apos;click&apos;, function (event) {

            // You can add custom JavaScript functionalities to your WebComponent
            let targetElement = event.target;
			console.log(targetElement);            

        }));
        console.log(`Jobsnippet: JobSnippet added to page`);
    };
    
};

window.customElements.define(&apos;job-snippet&apos;, JobSnippet);

try {
    const jobDiv = document.getElementById(&apos;jobs-{{tenant_uuid}}&apos;);
    
    fetch(url)
        .then((response) =&gt; {

            if(response.status == 200) {
                response.text().then((html) =&gt; {
                    template.innerHTML = html;
                    const jobSnippet = new JobSnippet();
                    jobDiv.appendChild(jobSnippet);
                });
            }
            else{
                console.error(&apos;Could not embed snippet&apos;)
                response.text().then((error) =&gt; console.error(&apos;Jobsnippet: &apos; + error));
            }
        });
    } catch (error) {
        console.error(error);
} </code></pre><figcaption>JavaScript for the entire WebComponent</figcaption></figure><p>As you can see a lot of stuff is happening here. Lets start at the very bottom of the script:</p><figure class="kg-card kg-code-card"><pre><code class="language-JavaScript">try {
const jobDiv = document.getElementById(&apos;jobs-{{tenant_uuid}}&apos;);

fetch(url)
    .then((response) =&gt; {

        if(response.status == 200) {
            response.text().then((html) =&gt; {
                template.innerHTML = html;
                const jobSnippet = new JobSnippet();
                jobDiv.appendChild(jobSnippet);
            });
        }
        else{
            console.error(&apos;Could not embed snippet&apos;)
            response.text().then((error) =&gt; console.error(&apos;Jobsnippet: &apos; + error));
        }
    });
} catch (error) {
    console.error(error);
}</code></pre><figcaption>Fetch call for our HTML part</figcaption></figure><p>This part is responsible for fetching the HTML code we prepared on our server before. We injected the <code>url</code> we need into our script by Django. Now we simply call that URL to retrieve the HTML code. If the call is successful with a <code>200</code> we take the response and set the <code>innerHTML</code> of our <code>template</code> element, create the JobSnippet and append it to the <code>div</code> we inserted together with our script. Otherwise we will log the error.<br><br>The <code>template</code> element is basically the first thing which is created by our script and serves as a container for our WebComponent. It is special and important for WebComponents. You can read more about it <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template">here</a>. </p><p>Lets take a look:</p><figure class="kg-card kg-code-card"><pre><code class="language-JavaScript">const template = document.createElement(&apos;template&apos;);
class JobSnippet extends HTMLElement {
    constructor() {
        super();
 
        const shadowRoot = this.attachShadow({mode: &apos;open&apos;});
        shadowRoot.appendChild(template.content.cloneNode(true));
    }

    connectedCallback() {
        let shadowHost = this.shadowRoot;
        const btns = shadowHost.querySelectorAll(&apos;.jobs-show-job-details&apos;);

        btns.forEach(element =&gt; element.addEventListener(&apos;click&apos;, function (event) {

            // You can add custom JavaScript to your WebComponent
            let targetElement = event.target;
			console.log(targetElement);            

        }));
        console.log(&quot;Jobsnippet: JobSnippet added to page&quot;);
    };
    
};</code></pre><figcaption>The custom web element</figcaption></figure><p>As you can see we create a class names <code>JobSnippet</code> which extends <code>HTMLElement</code>. This is responsible for creating a WebComponent. It is called a <a href="https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements">custom element</a>. Inside the constructor of this element is important that the <code>super()</code> always needs to get called first. </p><p>In the following we declare our Shadow DOM for our custom element:</p><p><code>const shadowRoot = this.attachShadow({mode: &apos;open&apos;});</code><br><br>This line creates s shadow DOM for us with the <code>open</code> <a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow">mode</a>. Open means that our custom element is still reachable from outside of the element via JavaScript. This can be useful for instance if the person who embeds the snippet needs to apply JavaScript to our custom component. You could also set the mode to <code>closed</code>. In that case the shadow root is not accessible via JavaScript from the outside.</p><p><code>shadowRoot.appendChild(template.content.cloneNode(true));</code></p><p>This adds our retrieved HTML content into our shadow root. Inside the the <code>connectedCallback()</code> function you can add all the functionalities you need to your component. This function gets called after the component has been attached to your page. In our demonstration case we add an onclick functionality to all buttons with a certain class.</p><p>The last important part for our custom element is this line: </p><p><code>window.customElements.define(&apos;job-snippet&apos;, JobSnippet);</code></p><p>This is neccessary to <a href="https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define">register</a> our custom element and to complete the creation of our custom component.</p><h3 id="conclusion">Conclusion</h3><p>To summarize things I can say that using a WebComponent for our task felt like the right decision. If you have never worked with WebComponents before the JavaScript setup of it might be a bit confusing but afterwards you will get an understanding pretty quickly. It is really helpful that you can control with the component mode (<code>open</code> or <code>closed</code>) whether the component is accessible from outside via JavaScript or not. The encapsulation feels very useful for purposes like snippet embedding.</p><p>What I think is also still worth mentioning that independent of the component mode the parent site is still able to override the styling ob the custom element with a special selector. Basically you can use the pseudo element <code>::part</code> for accessing the stylings of the shadow DOM. You can read more about it <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/::part">here</a>.<br>Accessing the styling from the parent site can look like the following:</p><figure class="kg-card kg-code-card"><pre><code class="language-CSS">snippet::part(job-entry-wrapper) {
		color: blue;	
}</code></pre><figcaption>CSS to access the WebComponent from outside</figcaption></figure><p>This would change the color of the part <code>job-entry-wrapper</code> inside the component <code>job-snippet</code> to <code>blue</code>. It is important to define those <code>parts</code> in your WebComponents HTML:<br><br> <code>&lt;div class=&quot;jobs-job-conent&quot; part=&quot;job-entry-wrapper&quot;&gt;</code></p><hr><p>Overall we are pretty happy how things worked out. There were no major confusions or hickups. The implementation worked well and after digging a bit into the WebComponent part I&apos;d would probably do it the same way in the future. Creating the HTML code server side seemed also like the right decision since we could easily integrate it into our custom element.</p>]]></content:encoded></item><item><title><![CDATA[How we created a room kiosk system with Vue 3 for Amazons Fire Tablet 7]]></title><description><![CDATA[In this article I want to explain how we created a room booking kiosk solution for one of our customers.]]></description><link>https://blog.gorala.icu/pwa-twa-kiosk/</link><guid isPermaLink="false">640d1aa282fb730001887d4b</guid><category><![CDATA[Frontend-Development]]></category><category><![CDATA[Vue]]></category><category><![CDATA[HTML]]></category><dc:creator><![CDATA[Marcel Schepaniak]]></dc:creator><pubDate>Tue, 18 Oct 2022 23:20:00 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwxMTc3M3wwfDF8c2VhcmNofDExfHx0cnVzdGVkJTIwd2ViJTIwYWN0aXZpdHl8ZW58MHx8fHwxNjU1ODg3OTYy&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwxMTc3M3wwfDF8c2VhcmNofDExfHx0cnVzdGVkJTIwd2ViJTIwYWN0aXZpdHl8ZW58MHx8fHwxNjU1ODg3OTYy&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=2000" alt="How we created a room kiosk system with Vue 3 for Amazons Fire Tablet 7"><p>In this article I want to explain how we created a room booking kiosk solution for one of our customers. I also want to give insights about challenges we encountered as well as talk about things we learned during this project. This will also summarize our thought process and how we finally came up with our technology decision for this project.</p><h3 id="our-situation">Our situation</h3><p>Our customer has a facility with a variety of different rooms staff members can book via a system which was developed by us in the past. I would describe this system as basic room booking system. The user can open a mobile or a web app and select a timeslot as well as a desired room and then perform a booking. If the room is not occupied at the given slot the booking will be added to the system and be visible to all other users.</p><p>The client came up with the idea to equip each bookable room with a simple tablet device which should serve some basic information and also allow the user do perform some basic booking related actions. The tablet is supposed to be installed at a wall inside of the room. The primary purpose of these tablets were the following:</p><!--kg-card-begin: markdown--><ol>
<li>Show whether the room is currently occupied</li>
<li>If the room is empty allow adhoc bookings for 15/30 minutes</li>
<li>Allow to directly stop the current booking of this room</li>
<li>Extend the current booking for 15/30 minutes</li>
<li>Show basic booking information like until when the room is booked or how long the room is not occupied as well as show a small timetable of todays bookings</li>
<li>Set a background image for each tablet and it&apos;s room</li>
</ol>
<!--kg-card-end: markdown--><p>All of the requirements basically serve the purpose to give the user who booked (or want to book) a room more flexibility and ease the related process. In case he recognizes that the meeting duration is far shorter or longer as expected he can easily stop or extend the current meeting. The user can also quickly and spontaneously book a room by just going into an empty room and press a button on the tablet.</p><p>Since the customer had a supply of Amazons Fire Tablets 7 left over the requirment was that the solution should run on these devices. </p><p>The requirements for the application itself were paired with some technical constraints the solution had to deliver:</p><!--kg-card-begin: markdown--><ol>
<li>The tablet must not dim and the screen must not lock</li>
<li>The app must not be closeable and be at the front at all time</li>
<li>The app should be easily startable via the home screen of the tablet</li>
<li>The app must be fullscreen</li>
</ol>
<!--kg-card-end: markdown--><h3 id="initial-outlaying-of-a-possible-solution-and-technology-selection">Initial outlaying of a possible solution and technology selection</h3><p>Given the requirements mentioned above we were discussing different approaches on how to implement a solution. Quickly we found out that we need more information about the Fire Tablet 7 to be able to properly choose a technology to tackle the requirements. It was obvious that it was important that our solution had to be able to run in a <a href="https://en.wikipedia.org/wiki/Kiosk_software">kiosk mode</a>. The focus had to be on the solution at all times and the user must not be able to close or hide it. We also were not exaclty sure what Android was running on the device and how heavily it got modified by Amazon and what exactly would be the implications of that modifications.</p><p>Some questions which came in our mind at that point primarly regarding the technical constraints were:</p><!--kg-card-begin: markdown--><ol>
<li>How can we achieve the kiosk mode? Is that something we need to enforce through Android permissions? Can we run a web app in kiosk mode?</li>
<li>Is the kiosk mode automatically solving the challenge that the device is not allowed to dim?</li>
<li>How can we provide a good update and setup process for the app and tablets?</li>
<li>Is this in general rather a web or mobile app case?</li>
</ol>
<!--kg-card-end: markdown--><p>After digging a little bit into the tablet we found out that the Fire Tablet 7 had indeed a build in kiosk mode which allows you to pin an app to the foreground. It then is only able to be hidden by performing a certain tap combination as well as entering a pin code. That was really good for us.</p><p>Unfortunatly the dimming was not direclty related to the kiosk mode. For instance when pinning a certain app the tablet screen fades black after a certain amount of time (which you can set in the settings max. 30 minutes). Completly disabling dimming is not supported by the Fire Tablet 7.</p><p>Regarding the Android version it was indeed heavily modified and the only <em>official </em>way to get apps onto the device was via the Amazon App Store. How ever we found out that it was possible to simply install third party APKs on the device like for instance Chrome or even Google Play. That was a bit sobering because we wanted to avoid getting our potential app into the official Amazon store. That was basically because getting your app in such a store usually takes time and effort. Also depending on the store you are confronted with different bureaucracy obstacles. We still could deploy our app manually in that case but deploying a new version would be quite exhausting and time consuming.</p><p>At this point we were still slightly favoring the development of a mobile app due to the following reasons:</p><!--kg-card-begin: markdown--><ul>
<li>Full control of the device for future requirements</li>
<li>No hassle with the screen dimming</li>
<li>Native feeling of the app</li>
</ul>
<!--kg-card-end: markdown--><p>But we still had some concerns:</p><!--kg-card-begin: markdown--><ul>
<li>How can we handle the update process? Does the client have to install the app from the Amazon App Store on each device? Is the update process automated?</li>
<li>What about app review queues in the Amazon App Store do they take long? What other requirements do we need to fulfill to get our app in there?</li>
<li>For our team creating a mobile app usually takes more time and effort than creating a web app. Would the extra effort pay off?</li>
</ul>
<!--kg-card-end: markdown--><p>Especially the update process of the apps seemed like a big disadvantage for the development of a mobile app (regardless of using the app store or deploying manually). In case there is a bug or we quickly need to change something on the client side we would firstly be confronted with the Amazon App Store review queue and afterwards the client would have to go to each tablet seperately and update the app as soon as the update is available (at least in the worst case). Also we could not rely on something like <a href="https://docs.fastlane.tools/">fastlane</a> to automate the deployment into the store since the Amazon App Store is not supported.</p><p>This led us to further research on how to solve this challenge by doing a web app. If we would do a web app we still had two issues to tackle though. Firstly how can we make sure that our web app is running in the kiosk mode of the Fire Tablet 7? Secondly how can we prevent the device from screen dimming without Android permissions? </p><p>Regarding the first point we chose a simple approach namely building a progressive web app (PWA). We could easily install the PWA by visiting the website and install it to the home screen. From there we hoped to simply use the build in kiosk mode with the PWA. This would ease the installation and update process enormously. It would not feel as native as a self written Android app but we could make it feel snappy with some extra work. The solution to go for a PWA later on turned out to be not well thought out and would cause some trouble.</p><p>For the second challenge we found out that it is possible to prevent the screen from dimming for web apps by an API. Some browser support something called a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API">Screen Wake Lock API</a>. We quickly created a proof of concept (PoC) and could validate that the API works under Chrome. A plain implementation is pretty straight forward:</p><!--kg-card-begin: markdown--><pre><code class="language-javascript">// Create a reference for the Wake Lock.
let wakeLock = null;

// create an async function to request a wake lock
try {
  wakeLock = await navigator.wakeLock.request(&apos;screen&apos;);
  statusElem.textContent = &apos;Wake Lock is active!&apos;;
} catch (err) {
  // The Wake Lock request has failed - usually system related, such as battery.
  statusElem.textContent = `${err.name}, ${err.message}`;
}
</code></pre>
<p><em>Taken from the Mozilla developer site</em></p>
<!--kg-card-end: markdown--><p>At this point we only had to make sure that Chrome was running on the tablet instead of Amazons default browser Silk. Since we were able to install APKs on the tablet that was quite easy. I also can recommened <a href="https://whatwebcando.today/">this site</a> which shows what web apps are currently capable of in what browser.</p><p>We were pretty sure that a PWA pinned to the app home screen would be most beneficial for us. We would have a deployment process which was not too effort consuming. Also we could benefit from our web app development expertise and create the app a little faster. The screen dimming would also be no problem due to the Wake Lock API. The benefits of an Android app seemed very minor at this point. Since it is a PWA we would also have no URL bar inside the browser and the app would be fullscreen. So we decided to go for a web app in form of a PWA.</p><p>The next question we had to answer was what frontend technology we would use. Our backend is mainly written in Python and Django. So we were playing around with the thought to embed the solution directly in our project and use server side rendering (SSR). How ever we felt like having an official API for this case could be useful and more flexible in the future. Furthermore using a frontend framework would ease the development and result in well a structured solution. I know that there are ways of integrating frameworks like Vue and Angular with Django but the integratrion effort did not seem to match the benefit. </p><p>Since we used Vue in the past and felt like we should decouple these systems and have an API we went for a single page application (SPA) with Vue3. In my opinion you could have also gone with plain HTML &amp; Javascript or any other frontend framework at this point. It is rather a matter of taste.</p><h3 id="the-development">The development</h3><p>I do not want to get in too much detail regarding the development process itself since it was pretty unspectacular. We were quickly setting up a Vue3 project and started programming. We prefered the composition API over the options API since we felt more comfortable with it. As state handling solution we chose <a href="https://pinia.vuejs.org/">Pinia</a>. As build tool we chose <a href="https://vitejs.dev/">Vite</a> instead of traditional <a href="https://cli.vuejs.org/">Vue CLI</a>. Both Pinia and Vite felt really good and I can just recommened them both. </p><p>Regarding the Wake Lock integration to prevent screen dimming we chose a <a href="https://vueuse.org/core/usewakelock/">dependency</a> which would ease the integration and use a little bit. This worked also really well.</p><p>What was not as easy as we hoped was the creation of the PWA itself. We found out that Vite&apos;s plugin system supports PWA. You can find it <a href="https://vite-plugin-pwa.netlify.app/">here</a>. The setup was good but I felt like the documentation was at some points a little bit unspecific. We managed to set everything up as said in the documentation how ever the required service worker did not register successfully. If you are experienced with PWAs this would be probably a no brainer for you but our issue was that we did not know that it is not enough to simply generate a service worker but we also had to implement a callback like the following in our app to register it and make it working:</p><!--kg-card-begin: markdown--><pre><code class="language-typescript">registerSW({
  onOfflineReady() {},
})
</code></pre>
<!--kg-card-end: markdown--><p>We did not need this functionality so we thought we could spare it. But without it Chrome would not see our app as PWA.</p><p>After we got this issue figured out we could finish the development. I have to add at this point that I feel like PWA development is not that much refined. We were struggling with spamming hard reloads, installing and uninstalling the PWA as well as server restards to always have the latest changes we made inside our app. This might be an issue with our development setup but I thought it is worth mentioning here in case anyone else runs into trouble with this. When developing a PWA make sure you are testing the right version of your app and not some old cached version from the browser.</p><h3 id="testing-and-encountering-a-major-issue-in-our-plan">Testing and encountering a major issue in our plan</h3><p>During development we tested mainly on our computers with the aspect ratio and dimensions of the Fire Tablet 7. We also had a staging with we could navigate to from the Chrome browser of the tablet. Since the PWA functionality was the last thing we implemented we simply opened the site and tested the app beforehand via the browser of the tablet.</p><p>This was a major issue during our development process. After finishing the PWA functionality and testing everything locally we wanted to test the PWA things on the tablet itself. Unfortunatly we found out quickly that the Fire Tablet 7 does not support PWAs. That was major issue for us since it was a requirment to pin the solution to the home screen as well as run the solution in fullscreen. We had not especially tested the PWA functionality on the device since we thought that it is surely supported. That was a major misassumption and since development was nearly completly finished and we had not much time left until a prototyping phase should start we felt like we might be ahead of some extra crunch.</p><p>We tried to set the browser to fullscreen mode and put a bookmark to the site on to the home screen of the tablet. But the Fire Tablet 7 neither supports one of it. Our first guess was that we had to create an Android app and embed a webview to our site to it. But that felt so wrong at this point because we consciously decided against creating an app. That would have mean we would get the worst of both worlds. So we kept researching if there is not a more charming solution.</p><h3 id="bubblewrapour-rescue">Bubblewrap - our rescue</h3><p>After some time we stumbled over something interesting called <a href="https://github.com/GoogleChromeLabs/bubblewrap">Bubblewrap</a>. The description from the GitHub repository is quite accurate so I will just put it here:</p><blockquote>Bubblewrap is a set of tools and libraries designed to help developers to create, build and update projects for Android Applications that launch Progressive Web App (PWA) using <a href="https://developers.google.com/web/android/trusted-web-activity/" rel="nofollow">Trusted Web Activity (TWA)</a>.</blockquote><p>That was so fitting to our current situation since we had a working PWA but it was simply not supported by the Fire Tablet 7. We were a little bit hesitant at first because from experience those solutions tend to break some functioanlity or cause some other issues.</p><p> A TWA is described as the following:</p><blockquote>Trusted Web Activity is a new way to open <em>your</em> web-app content such as <em>your</em> Progressive Web App (PWA) from <em>your</em> Android app using a protocol based on Custom Tabs.</blockquote><blockquote>Note: Trusted Web Activity is available in <a href="https://play.google.com/store/apps/details?id=com.android.chrome">Chrome on Android</a>, version 72 and above.</blockquote><p> Our biggest concern at this point was whether the Wake Lock would still be working. We were especially skeptical since the Fire Tablet 7 default browser was Silk. Also PWAs were not supported. If we would use a TWA it would internally probably use Silk to launch it which would mean that the Wake Lock API probably is not supported. How ever as described in the documentation the following behavior is used by a TWA (keep in mind that we had installed the newest Chrome version manually on the tablet).</p><blockquote>A Trusted Web Activity will try to adhere to the user&apos;s default choice of browser. If the user&apos;s default browser supports Trusted Web Activities, it will be launched. Failing that, if any installed browser supports Trusted Web Activities, it will be chosen. Finally, the default behavior is to fall back to a Custom Tabs mode.</blockquote><p>We set up Bubblewrap like explained in the <a href="https://developer.chrome.com/docs/android/trusted-web-activity/quick-start/">setup guide</a> (which was surprisingly easy) and started the process with the following command:</p><!--kg-card-begin: markdown--><pre><code>bubblewrap init --manifest=https://my-twa.com/manifest.json
</code></pre>
<!--kg-card-end: markdown--><p>That obviously enforces that you have a version of your website live and running so that Bubblewrap can access your PWA manifest. I&apos;m not sure if Bubblewrap also supports local PWA manifests. We had to fill in some information during the process and everything worked on the first try. After that we simply ran:</p><!--kg-card-begin: markdown--><pre><code>bubblewrap build
</code></pre>
<!--kg-card-end: markdown--><p>Et voil&#xE0; the product was indeed a functional APK file which we could simply install on the Fire Tablet 7. Everything was working like in the PWA. Even the use of the Wake Lock API (probably because the TWA was launched in Chrome and not in Silk). We were really a bit surprised at this point because it did not take more than half a hour to convert our PWA to an APK. &#xA0;One thing which might be a bit tricky and you should definetly watch out for is the <a href="https://developer.chrome.com/docs/android/trusted-web-activity/android-for-web-devs/#digital-asset-links">digital asset link</a> between your app and your website. If not set up correctly your app will display a costum browser UI at the top. </p><p>Afterwards we simply deployed the APK to the tablet and could easily run updates to our site which were visible after a reload of the TWA. No need of deploying a new APK was neccessary.</p><h3 id="learnings-conclusion">Learnings &amp; Conclusion</h3><p>Finally I quickly want to discuss the takeaways for us of this story. </p><!--kg-card-begin: markdown--><ol>
<li>Perform full early tests of your possible technology choice on your target device</li>
</ol>
<p>Our biggest mistake obviously was that we assumed that a PWA was definetly running on the Fire Tablet 7. If we would have tested that within the PoC our development process might have changed. To make early unverfified assumptions on a unfamiliar system is not good - be sure to verify it.</p>
<ol start="2">
<li>Vue3, Vite &amp; Pinia were a good choice</li>
</ol>
<p>The technology choice itself felt really good for our use case. We had a small encapsulated and expandable system created with an up to date technology. I definetly can recommend this technology choice for a project at this scale.</p>
<ol start="3">
<li>Amazons Fire Tablets are very limited</li>
</ol>
<p>This one probably speaks for itself, right? It is not possible to disable screen dimming. By default you only have access to the Amazon App Store. The systems base browser is Silk which is quite unknown and might behave different compared to state of the art browsers. No PWAs or website bookmarking on the home screen is supported.</p>
<p>It is also worth mentioning that after some research it seems like these are not only limitations for the Fire Tablet 7 but also apply to more current versions of the Fire Tablet series. Be sure to double check!</p>
<ol start="4">
<li>Bubblewrap and TWAs seem really good</li>
</ol>
<p>For our use case and situation we owe the people at the Google Chrome Labs a thank you. The technology worked really well. Of course I can not speak for more complex projects but for our usecase this was really good. The setup and the CLI were charming. We will defenitly keep an eye on it and evaluate more usecases.</p>
<ol start="5">
<li>PWA development felt a little bit clonky</li>
</ol>
<p>I think this is heavily opinion intensive but the development of the PWA itself did not feel so convincing. This may correlate with a little bit of inexperience in that field but creating the manifest and registering the service workers felt very error prone. If you managed to set up everything correctly the debugging and working with the PWA can also be quite frustrating. It results in some work to reload everything properly after some changes, uninstall and reinstall the app. I think this is also quite error prone. How ever this might be related me not having that much experience in developing PWAs.</p>
<!--kg-card-end: markdown--><p>In the end we were quite happy with the way how things worked out. I think in the end we would have achieved the same result directly developing an app. How ever the installation and update process would be costly for the client. If you have access to the PlayStore or Apple Appstore I think things could be a bit easier. Learnings were big in this one for us and we were really pleased to see how well the deployment through the TWA finally went.</p>]]></content:encoded></item><item><title><![CDATA[How to host a static site with Caddy, my own domain and docker-compose]]></title><description><![CDATA[In this tutorial I want to briefly discuss how to host a static website with Caddy2 and Docker]]></description><link>https://blog.gorala.icu/how-to-host-a-static-site/</link><guid isPermaLink="false">640d1a443a746e0001e388fe</guid><category><![CDATA[Docker]]></category><dc:creator><![CDATA[Marcel Schepaniak]]></dc:creator><pubDate>Mon, 04 Apr 2022 23:18:00 GMT</pubDate><media:content url="https://blog.gorala.icu/content/images/2022/06/caddy-open-graph.jpg" medium="image"/><content:encoded><![CDATA[<img src="https://blog.gorala.icu/content/images/2022/06/caddy-open-graph.jpg" alt="How to host a static site with Caddy, my own domain and docker-compose"><p>In this tutorial I want to briefly discuss how to host a static website with Caddy2 and Docker. This means you will have a static website running with HTTPS available at your domain.</p><h3 id="prerequisites">Prerequisites:</h3><!--kg-card-begin: markdown--><ol>
<li>A static website project. This can be a single HTML file for instance.</li>
<li>Access to a server where Docker &amp; docker-compose is available</li>
<li>A domain you own</li>
</ol>
<!--kg-card-end: markdown--><h3 id="what-is-caddy">What is Caddy?</h3><p>Caddy is an open source webserver with build in automatic HTTPS. That means you do no need to take care about ensuring that your SSL certificates are valid and most importantly stay valid. In this tutorial Caddy will serve our static HTML project to the viewer (note that Caddy is much more capable of though). You could alternatively also use something like <a href="https://www.nginx.com/">NGINX</a> for example. How ever in that case you would have to take care about your certificates.</p><h3 id="setup">Setup</h3><p>In this tutorial we will use <code>docker-compose</code> to run Caddy. If you plan to proxy multiple services through Caddy you can later on chain them together in that file. There are plenty of other ways to get Caddy going easily like running it manually by downloading or cloning the Git repository or use simple single docker container without docker compose.</p><p>Lets get started with setting up Caddy.</p><ol><li>Create a <code>docker-compose.yml</code>.</li></ol><p>The file should have to following content:</p><!--kg-card-begin: markdown--><pre><code class="language-yml">version: &apos;3&apos;
services:
  caddy:
    image: caddy:2.1.1-alpine
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./personal-site/personal-site:/usr/share/caddy
      - caddy_data:/data
volumes:
  caddy_data:
  # caddy SSL volume      
</code></pre>
<!--kg-card-end: markdown--><p>The file is pretty straight forward I think except maybe for the volumes part. The first line <code>./Caddyfile:/etc/caddy/Caddyfile</code> mounts our Caddyfile into the container. The <code>Caddyfile</code> is somewhat the configuration file our Caddy server needs. In this file we will later on configure which domain Caddy will use for our server.<br><br>The second line <code>./personal-site:/usr/share/caddy</code> mounts our static website folder into the Caddy share folder. This folder will be later on shared through our server.</p><p>In this example we assume that our <code>Caddyfile</code> as well as our personal-site static folder are in the same directory as our <code>docker-compose.yml</code>.</p><p>The third line <code>caddy_data:/data</code> is required for our SSL volume.</p><p>2. Create a <code>Caddyfile</code> at the specified location of your <code>docker-compose.yml</code></p><p>Fill the Caddyfile with the following:</p><!--kg-card-begin: markdown--><pre><code>yousubdomain.domain.domainsuffix {
  root * /usr/share/caddy
  file_server
}
</code></pre>
<!--kg-card-end: markdown--><p>You are telling caddy to serve the folder under <code>/usr/share/caddy</code>. In our <code>docker-compose.yml</code> we specified our static site project folder for this. Make sure you set up a record in your domain management system. </p><p>When starting our Caddy server it will be available on port 80 and serve the static site with its assets located in the given folder.</p><h3 id="start-the-service">Start the service </h3><p>With <code>sudo docker-compose up -d</code> you can then start your Caddy server. Remove the <code>-d</code> if you want to see logs and get rid of the detached mode. Caddys first setup might take some time since it will take care of the neccessary certificates.</p><p>You can now navigate to your domain with HTTPS as protocol and should see your static site.</p><p> </p>]]></content:encoded></item></channel></rss>