← all posts · hugo

Adding copyright watermarks to images with Hugo's asset pipeline

How to stamp a copyright notice onto every image at Hugo build time using images.Text — including the font trap, the shadow technique for readability, and how to keep multiple shortcodes in sync.

27 Apr 2026 · 6 min read · Stephen Masters hugodevops

Hugo’s extended image processing pipeline includes an images.Text filter that can stamp text onto images at build time. This post shows how to use it to add a copyright watermark — covering the font requirement, a shadow technique for legibility on varied backgrounds, and a non-obvious consistency requirement when the same image is processed in more than one template.


Prerequisites: images must be in assets/

Hugo’s image processing only works on resources in the assets/ directory. Files in static/ are served as-is and cannot be processed.

If your images are in static/images/, you’ll need to move them to assets/images/ first. Once there, use resources.Get and resources.Match instead of path string construction, and use .RelPermalink or .Permalink on the resulting resource instead of building URLs manually.


The basic pattern

For a gallery lightbox image at 1920px:

go-html-template
123456789
{{- $base := .Resize "1920x webp" -}}
{{- $wm := images.Text "© 2025 Stephen Masters" (dict
    "color" "#ffffff"
    "size"  16
    "font"  $font
    "x"     (sub $base.Width 260)
    "y"     (sub $base.Height 26)
) -}}
{{- $full := $base | images.Filter $wm -}}

images.Text returns a filter. images.Filter applies it to the image and returns a new image resource. The original is unchanged.

The x and y parameters are the pixel coordinates of the top-left corner of the text, measured from the top-left of the image. To position in the bottom-right corner, subtract from the image’s .Width and .Height after resizing — you need to resize first to know the dimensions.


The font trap: Hugo’s default font is ASCII-only

Here’s the problem that trips almost everyone:

Hugo’s default font for images.Text is Go’s basicfont.Face7x13 — a small bitmap font that covers printable ASCII (characters 0x20–0x7E). The copyright symbol © is Unicode U+00A9. It is not ASCII. If you use the default font, the © character will not render — you’ll get a blank or the character will be silently dropped.

To use ©, you must provide a TrueType font via the font parameter:

go-html-template
12345678
{{- $font := resources.Get "fonts/watermark.ttf" -}}
{{- $wm := images.Text "© 2025 Stephen Masters" (dict
    "color" "#ffffff"
    "size"  16
    "font"  $font
    "x"     (sub $base.Width 260)
    "y"     (sub $base.Height 26)
) -}}

A good choice is DejaVu Sans — open-source (Bitstream Vera / SIL licence, freely redistributable), wide Unicode coverage, and a reasonable visual weight for a watermark. Place the .ttf file at assets/fonts/watermark.ttf.


Making it legible: the shadow technique

A plain white watermark on a white or light background is invisible. A dark watermark on a dark background is equally invisible. Since photos vary widely in tone and colour, any single-colour text will disappear somewhere.

The solution is a drop shadow: apply two text filters in sequence — a dark semi-transparent layer offset by one pixel, then the main white text on top.

go-html-template
123456789
{{- $font := resources.Get "fonts/watermark.ttf" -}}
{{- $year := .Page.Date.Format "2006" -}}
{{- $copyright := printf "© %s Stephen Masters" $year -}}
{{- $base := .Resize "1920x webp" -}}
{{- $wmX := sub $base.Width 260 -}}
{{- $wmY := sub $base.Height 26 -}}
{{- $shadow := images.Text $copyright (dict "color" "#000000cc" "size" 16 "font" $font "x" (add $wmX 1) "y" (add $wmY 1)) -}}
{{- $text   := images.Text $copyright (dict "color" "#ffffff"   "size" 16 "font" $font "x" $wmX         "y" $wmY        ) -}}
{{- $full := $base | images.Filter $shadow $text -}}

images.Filter accepts multiple filters and applies them in order. The dark shadow (80% opacity, 1px down-right) provides contrast against light areas; the full-white text sits on top and reads against dark areas.


Using the article date for the year

Rather than hardcoding the year, use the article’s date front matter field. This means 2024 articles automatically get “© 2024” and 2025 articles get “© 2025”:

go-html-template
12
{{- $year := .Page.Date.Format "2006" -}}
{{- $copyright := printf "© %s Stephen Masters" $year -}}

In shortcode context .Page.Date is available directly. In a layout template (e.g. _default/single.html) use $.Date.


Which images to watermark

Not every processed image needs a watermark. The priority is the full-size images that are actually worth copying:

Image typeSizeWatermarked
Gallery lightbox1920pxYes — primary sharing target
Inline article images1200pxYes
Article hero1400pxYes
Gallery thumbnails800pxNo — too small to be useful
Route card thumbnails640pxNo — too small

The multi-shortcode consistency requirement

This is the non-obvious part.

On Velostevie, the same gallery images are processed in two places:

  • gallery.html shortcode — produces thumbnail + lightbox versions; the lightbox data-src URL points to the processed image
  • gpxmap.html shortcode — produces a full-size version for each GPS-tagged photo; the marker URL in data-photo-markers JSON points to the processed image

When a user clicks a map marker, JavaScript matches the marker URL against the gallery’s data-src to open the lightbox. This match must succeed.

Hugo’s image pipeline caches processed images by their source file plus their processing operations. If gallery.html applies a watermark and gpxmap.html does not (or applies different parameters), they produce different processed images with different URLs — and the click-through silently fails.

The fix: both shortcodes must apply identical filter parameters. Same font, same colour, same size, same offsets. Then Hugo produces the same cached image resource in both places, and the URLs match.

go-html-template
123456
{{- /* In both gallery.html AND gpxmap.html — identical */ -}}
{{- $wmX := sub $base.Width 260 -}}
{{- $wmY := sub $base.Height 26 -}}
{{- $shadow := images.Text $copyright (dict "color" "#000000cc" "size" 16 "font" $font "x" (add $wmX 1) "y" (add $wmY 1)) -}}
{{- $text   := images.Text $copyright (dict "color" "#ffffff"   "size" 16 "font" $font "x" $wmX         "y" $wmY        ) -}}
{{- $full := $base | images.Filter $shadow $text -}}

Since both shortcodes are on the same page, .Page.Date.Format "2006" produces the same year in both. The image path is the same. The operations are identical. Hugo returns the same cached file.


Cache invalidation

When you change watermark parameters (colour, size, position), Hugo does not automatically reprocess the cached images. The cache at resources/_gen/images/ stores the result of each unique combination of source image + processing operations. Changing a filter parameter changes the cache key, so in theory a fresh build would produce the new version.

In practice, the dev server can serve stale content. To force a clean rebuild:

bash
12
rm -rf resources/_gen/images/
npm run start

This is especially important during watermark tuning — if the text looks wrong and you’ve already made template changes, clearing the cache is the first thing to try.


Sizing the x offset

The x parameter positions the left edge of the text. To avoid the text wrapping at the image edge, you need to leave enough room for the full text width.

With DejaVu Sans at 16px, “© 2025 Stephen Masters” is approximately 230px wide. Using sub $base.Width 260 places the left edge at 1660px on a 1920px image, leaving 260px of space to the right edge — comfortably more than 230px. If you change the font, size, or name, you may need to adjust this offset.

There is no programmatic way to get the rendered text width from images.Text — you can only work empirically. Add 20–30px of buffer beyond your estimate and inspect the result.


Summary

  • Images must be in assets/ to use Hugo’s processing pipeline
  • Hugo’s default basicfont is ASCII-only — use a TrueType font for ©
  • A drop shadow (two sequential filters) gives readability on any background
  • Use .Page.Date.Format "2006" for an automatically correct copyright year
  • When the same image is processed in multiple templates, all must apply identical filter parameters or processed-image URLs will diverge
  • Clear resources/_gen/images/ when changing filter parameters to avoid serving stale cached images
SM
Stephen Masters

Software developer and architect. I build systems for places that move energy, commodities, and money around. I keep a bike-packing journal at velostevie.com.