Contents

Blog 10-07-25

It was time. My trusty old VPS, which had been faithfully collecting website analytics via the fantastic open-source tool Umami, was starting to show its age. Rather than patching it up, I decided to migrate everything to a fresh, new Ubuntu 24.04 server.

Spoiler alert: it was mostly smooth sailing, but I did get haunted by one particularly stubborn PostgreSQL error. If you’re planning a similar move, follow along, and I’ll show you how I solved it.

Umami

My mission was simple: get the latest Umami (v2.19) running on a new VPS with the newest PostgreSQL (v18). The official Umami documentation is a great starting point, but for the specific nuances of my setup, I knew I needed more.

I decided to use a combination of guides:

  1. My own previous post on setting up Umami for the core Umami-specific steps.
  2. A fantastic guide for PostgreSQL 18 on Ubuntu 24.04, as the default repos don’t have it yet.

Let’s break it down.

Ubuntu 24.04 is a fantastic base, but its default repositories ship with an older PostgreSQL. To get the latest performance and security improvements of version 18, I followed this excellent guide from dev.to: PostgreSQL 17 Installation on Ubuntu 24.04.

Don’t be fooled by the title! The process is identical for version 18. The key steps are:

  1. Add the Official Repository: This gives us access to the latest versions.
    sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
  2. Import the Repository Signing Key:
    wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo tee /etc/apt/trusted.gpg.d/pgdg.asc &>/dev/null
  3. Update and Install:
    sudo apt update
    sudo apt install postgresql-18

Once installed, the service runs automatically. You can create a dedicated database and user for Umami by switching to the postgres user and using the psql command-line tool.

With the database ready, it was time to install Umami itself. I largely followed the process from :(far fa-bookmark): my old blog post, which involves:

  • Installing Node.js and npm.
  • Cloning the Umami GitHub repository.
  • Installing the dependencies with npm install.
  • Configuring the .env file with the new database connection string.
  • Building the application and running the database seed script.

Everything went perfectly. The build was successful, the database schema was populated, and I could access the Umami login page.

Confident that everything was working, I decided to deploy my tracking script to a test site hosted on Vercel. The moment of truth… and it failed.

The Vercel function was throwing a nasty error: permission denied for schema public.

My local setup worked, but the remote connection from Vercel couldn’t insert any data. The issue was one of permissions. In PostgreSQL, when a new user is created, they don’t automatically get USAGE and CREATE privileges on the public schema, even for their own database!

The solution was found in a lifesaving Stack Overflow answer. It’s a simple one-liner, but it makes all the difference.

Here’s how to fix it:

  1. Log into your psql prompt as the postgres user or a superuser.

  2. Connect to the Umami database: \c your_umami_database_name;

  3. Run this crucial command for your Umami user (in my case, umami):

    GRANT ALL ON SCHEMA public TO umami;

That’s it! This single command grants the necessary permissions for the umami user to create tables, functions, and, most importantly, to insert new event data into the existing tables within the public schema.

The moment I ran this and refreshed my test site, the analytics data started flowing in. The ghost was banished.

This migration was a great reminder of a few key things:

  1. The official docs are your friend, but sometimes you need a more specific map. Combining guides is often the best approach.
  2. PostgreSQL permissions are strict, and for good reason. A fresh install often requires this extra permission step, which is easy to overlook if you’re used to more permissive defaults.
  3. Umami continues to be an absolute champion. It’s lightweight, privacy-focused, and a breeze to self-host once you get past these small infrastructure hurdles.

I discovered this talented piano guy Riyandi Kusuma:



If you’ve ever created custom shortcodes in Hugo and encountered mysterious broken image icons, you’re not alone. Today we’re diving into a common issue with image shortcodes and exploring why one approach works while another fails.


Recently, I encountered an issue where two seemingly similar Hugo shortcodes behaved completely differently. The first one displayed images perfectly, while the second showed broken link icons. Let’s examine both approaches and understand what went wrong.

{{- $options := cond .IsNamedParams (.Get "src") (.Get 0) | dict "Src" -}}
{{- $options = cond .IsNamedParams (.Get "alt") (.Get 1) | .Page.RenderString | dict "Alt" | merge $options -}}
{{- $caption := cond .IsNamedParams (.Get "caption") (.Get 2) | .Page.RenderString -}}
{{- $options = dict "Caption" $caption | merge $options -}}

{{- $optim := slice 
    (dict "Process" "resize 800x webp q75" "descriptor" "800w")
    (dict "Process" "resize 1200x webp q75" "descriptor" "1200w")
    (dict "Process" "resize 1600x webp q75" "descriptor" "1600w")
-}}
{{- $options = dict "OptimConfig" $optim | merge $options -}}
{{- $options = dict "Loading" "lazy" | merge $options -}}

{{- if .IsNamedParams -}}
    {{- $options = dict "Title" (.Get "title") | merge $options -}}
    {{- $options = dict "Height" (.Get "height") | merge $options -}}
    {{- $options = dict "Width" (.Get "width") | merge $options -}}
    {{- $options = .Get "linked" | ne false | dict "Linked" | merge $options -}}
    {{- $options = dict "Rel" (.Get "rel") | merge $options -}}
{{- else -}}
    {{- $options = cond $caption true false | dict "Linked" | merge $options -}}
{{- end -}}

{{- $options = dict "Resources" .Page.Resources | merge $options -}}

{{- with $caption -}}
    <figure{{ with cond $.IsNamedParams ($.Get "class") "" }} class="{{ . }}"{{ end }}>
        {{- partial "plugin/image.html" $options -}}
        <figcaption class="image-caption">
            {{- . | safeHTML -}}
        </figcaption>
    </figure>
{{- else -}}
    {{- partial "plugin/image.html" $options -}}
{{- end -}}
{{- $options := dict
    "Src" (.Get "src")
    "Alt" (.Get "alt")
    "Caption" (.Get "caption")
    "Linked" (.Get "linked" | default true)
    "InitialX" (.Get "data-initial-x" | default "50")
    "InitialY" (.Get "data-initial-y" | default "50")
    "TextboxContents" (.Get "textbox_content")
    "SrcSmall" (.Get "src_s")
    "SrcLarge" (.Get "src_l")
    "Height" (.Get "height")
    "Width" (.Get "width")
    "Rel" (.Get "rel")
    "HoverTooltip" (.Get "hover-tooltip") -}}
  
{{- $imageSrc := $options.Src | absURL -}}
{{- $small := $options.SrcSmall | default $imageSrc | absURL -}}
{{- $large := $options.SrcLarge | default $imageSrc | absURL -}}
{{- $alt := $options.Alt | default $imageSrc -}}

{{- $options = $options | merge (dict "Resources" .Page.Resources) -}}

{{- if $options.Caption -}}
  <figure{{ with cond $.IsNamedParams ($.Get "class") "" }} class="{{ . }}"{{ end }}>
    <div class="image-container">
      {{- partial "plugin/image.html" (dict "Src" $imageSrc "Alt" $alt "Caption" $options.Caption "Linked" $options.Linked "SrcSmall" $small "SrcLarge" $large "Height" $options.Height "Width" $options.Width "Rel" $options.Rel "Loading" "lazy") -}}
    </div>
    <figcaption class="image-caption">
      {{- $options.Caption | safeHTML -}}
    </figcaption>
  </figure>
{{- else -}}
  <div class="image-container">
    {{- partial "plugin/image.html" (dict "Src" $imageSrc "Alt" $alt "Linked" $options.Linked "SrcSmall" $small "SrcLarge" $large "Height" $options.Height "Width" $options.Width "Rel" $options.Rel "Loading" "lazy") -}}
  </div>
{{- end -}}

The critical difference lies in how each code handles image paths. The broken code uses:

{{- $imageSrc := $options.Src | absURL -}}

This seems logical at first glance - we’re converting relative paths to absolute URLs. However, this approach bypasses Hugo’s powerful image processing pipeline.

  1. Bypasses Resource Pipeline: When you use absURL, Hugo treats the image as an external URL rather than a local resource that needs processing
  2. No Optimization: The image processing configurations (WebP conversion, resizing, etc.) in your plugin/image.html partial can’t be applied
  3. Missing Resource Context: The connection to .Page.Resources is lost, so Hugo can’t find and process the local image file

Here’s the corrected version that maintains compatibility with Hugo’s image processing:

{{- $options := dict
    "Src" (.Get "src")
    "Alt" (.Get "alt")
    "Caption" (.Get "caption")
    "Linked" (.Get "linked" | default true)
    "InitialX" (.Get "data-initial-x" | default "50")
    "InitialY" (.Get "data-initial-y" | default "50")
    "TextboxContents" (.Get "textbox_content")
    "SrcSmall" (.Get "src_s")
    "SrcLarge" (.Get "src_l")
    "Height" (.Get "height")
    "Width" (.Get "width")
    "Rel" (.Get "rel")
    "HoverTooltip" (.Get "hover-tooltip")
    "Resources" .Page.Resources -}}

{{- if $options.Caption -}}
  <figure{{ with cond $.IsNamedParams ($.Get "class") "" }} class="{{ . }}"{{ end }}>
    <div class="image-container">
      {{- partial "plugin/image.html" $options -}}
    </div>
    <figcaption class="image-caption">
      {{- $options.Caption | safeHTML -}}
    </figcaption>
  </figure>
{{- else -}}
  <div class="image-container">
    {{- partial "plugin/image.html" $options -}}
  </div>
{{- end -}}

If your partial expects individual parameters rather than an options dict:

{{- $src := .Get "src" -}}
{{- $alt := .Get "alt" -}}
{{- $caption := .Get "caption" -}}
{{- $srcSmall := .Get "src_s" -}}
{{- $srcLarge := .Get "src_l" -}}

{{- if $caption -}}
  <figure{{ with cond $.IsNamedParams ($.Get "class") "" }} class="{{ . }}"{{ end }}>
    <div class="image-container">
      {{- partial "plugin/image.html" (dict 
          "Src" $src 
          "Alt" $alt 
          "Caption" $caption
          "Linked" (.Get "linked" | default true)
          "SrcSmall" $srcSmall
          "SrcLarge" $srcLarge
          "Height" (.Get "height")
          "Width" (.Get "width")
          "Rel" (.Get "rel")
          "Loading" "lazy"
          "Resources" .Page.Resources) -}}
    </div>
    <figcaption class="image-caption">
      {{- $caption | safeHTML -}}
    </figcaption>
  </figure>
{{- else -}}
  <div class="image-container">
    {{- partial "plugin/image.html" (dict 
        "Src" $src 
        "Alt" $alt
        "Linked" (.Get "linked" | default true)
        "SrcSmall" $srcSmall
        "SrcLarge" $srcLarge
        "Height" (.Get "height")
        "Width" (.Get "width")
        "Rel" (.Get "rel")
        "Loading" "lazy"
        "Resources" .Page.Resources) -}}
  </div>
{{- end -}}
  1. Never use absURL for local images that need Hugo processing
  2. Always pass .Page.Resources to your image partials
  3. Let Hugo’s pipeline handle URL generation - it knows how to create proper URLs for processed images
  4. Keep paths raw until they reach the image processing partial

The absURL function is still useful for:

  • External images that don’t need processing
  • Static assets that aren’t part of Hugo’s resource pipeline
  • CDN URLs that are already absolute

Understanding Hugo’s resource pipeline is crucial for working with images effectively. By letting Hugo handle the entire image processing workflow - from finding local resources to generating optimized output - you ensure that all the powerful features like responsive images, format conversion, and lazy loading work as intended.

The next time you encounter broken images in your Hugo shortcodes, check if you’re accidentally bypassing the resource pipeline with premature URL conversion!