mysite/content/posts/new_features.md

759 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

+++
title = "New features for my blog! Adding Comments + RSS to Hugo PaperMod (Giscus and Isso FTW)"
description = "A friendly, copy-pasteable guide to wiring up Giscus comments (GitHub Discussions) and Isso comments + RSS in Hugo PaperMod."
date = 2025-10-23T19:31:12+02:00
draft = false
slug = "comments-rss-papermod"
ShowToc = true
TocOpen = true
tags = ["hugo", "papermod", "giscus", "rss", "comments"]
categories = ["blog", "meta"]
+++
> Short story: I wanted comments and a proper RSS feed on my Hugo + PaperMod site.
> Longer story: I spent 30 minutes poking around, and now you dont have to. Heres exactly what I did.
---
I've been wanting to add comment section to my blog for a while.
There are a few problems.
- I don't want to get attacked by bots or random people for no reason.
- I don't want to allow weird things to happen.
- I don't want to have a database for it.
So... I decided to take a path which is not necesserially the best.
It requires a third party to act on your behalf, but it is not as scary as it sounds.
According to Github:
> **What the app can do**
> The apps rights are limited to the repo(s) where the site owner installed it and to the permissions it requested (basically: Discussions read/write + metadata). It cant touch a commenters private repos or do things outside those scopes. GitHubs docs also say a GitHub App can only do what both you and the app have permission to do.
> **What “Act on your behalf” means**
> That scary wording is GitHubs. The giscus maintainer explains it means “post comments under your username,” and shows that the app requests Discussions read/write + metadata when installed.
> **Do commenters have alternatives?**
> Yes. Anyone can open the linked GitHub Discussion and comment directly on GitHub, bypassing the apps OAuth popup. The README explicitly mentions this.
> **Privacy & storage**
> No tracking/ads; comments live in GitHub Discussions. giscus is open-source and has a published privacy policy. You can also revoke the app any time in your GitHub settings.
With the notice out of the way let's dive right in!
---
## Why I chose Giscus (instead of Disqus)
- **No ads or trackers** (my page weight thanks me).
- **Uses GitHub Discussions** (portable, transparent, easy to search).
- **Readers log in with GitHub** (no mystery auth popups).
- In theory you can directly go to the repo's discussion section and comment there!
---
## What you need (for your site, not per user)
- A Hugo site using **PaperMod**
- A GitHub account ✅
- A **public** repository for the discussion threads (I called mine `blog-comments`) with **Discussions** enabled ✅
- Giscus app installed on that repo ✅
If you like to keep your main site repo clean, use a separate one, e.g. `github-username/blog-comments`.
---
## 1) Turn on comments with Giscus
PaperMod renders a `comments.html` partial when `params.comments = true`.
So we enable it in config and create the partial.
### 1.1 Enable comments globally
`hugo.toml` (snippet)
```toml
[params]
comments = true
```
You can still opt out per post with `comments = false` in front matter.
### 1.2 Create the comments partial
Create this file **in your site** (not inside the theme):
```
layouts/partials/comments.html
```
Paste the Giscus embed (swap the placeholders with yours from giscus.app):
```html
<section id="comments" class="giscus">
<script
src="https://giscus.app/client.js"
data-repo="yourname/blog-comments"
data-repo-id="YOUR_REPO_ID"
data-category="Comments"
data-category-id="YOUR_CATEGORY_ID" <!-- Get them from Giscus's page! -->
data-mapping="pathname"
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="bottom"
data-theme="preferred_color_scheme"
data-lang="en"
data-loading="lazy"
crossorigin="anonymous"
async>
</script>
<noscript>Enable JavaScript to view comments powered by Giscus.</noscript>
</section>
```
**Notes**
- Youll get `repo-id` and `category-id` from **giscus.app** after picking your repo + category.
- `data-mapping="pathname"` works great for static sites.
- Prefer a **public** repo so visitors can read discussions without friction.
- Want a different language? Change `data-lang` (e.g., `de`, `fr`).
---
## 2) Enable RSS (home + sections)
Hugo already knows how to generate feeds; we just tell it where.
`hugo.toml`
```toml
baseURL = "https://example.com/" # set your production URL
[outputs]
home = ["HTML", "RSS", "JSON"] # JSON is optional (e.g., for on-site search)
section = ["HTML", "RSS"]
taxonomy = ["HTML", "RSS"]
term = ["HTML", "RSS"]
[services.rss]
limit = -1 # -1 = no cap; or set e.g. 20
```
**Feed URLs**
- Home feed: `/index.xml``https://example.com/index.xml`
- Section feeds: `/posts/index.xml`, `/notes/index.xml`, etc. (folders are lowercase)
> If youre using a `.onion` address or staging domain, just set that in `baseURL`.
> Locally, Hugo serves feeds at `http://localhost:1313/index.xml`.
---
## 3) Show an RSS button on the home page
PaperMod shows a nice RSS icon on **section/taxonomy** lists by default (when enabled), but not on the home page. Two options:
### Option A — Use the home “social icons” row
This appears if you enable **Home-Info** or **Profile** mode:
```toml
[params.homeInfoParams]
Title = "Hello, internet!"
Content = "Notes, experiments, and occasional rabbit holes."
[[params.socialIcons]]
name = "rss" # lowercase matters
url = "/index.xml"
```
### Option B — Add an icon in the footer (theme-safe)
Create:
```
layouts/partials/extend_footer.html
```
Paste:
```html
<a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS" class="rss-link">
{{ partial "svg.html" (dict "name" "rss") }}
<span>RSS</span>
</a>
```
Make it a sensible size:
Create:
```
assets/css/extended/rss.css
```
Paste:
```css
/* Small & aligned RSS icon in the footer */
.rss-link { display: inline-flex; align-items: center; gap: .35rem; }
.rss-link svg { margin-left: 10px; width: 18px; height: 18px; } /* a bit of left margin for good measure!*/
.rss-link span { font-size: .95rem; }
```
PaperMod auto-bundles anything under `assets/css/extended/`, so no extra imports needed.
---
## 4) Verify everything
Build or run dev:
```bash
hugo # outputs to ./public
# or
hugo server # serves at http://localhost:1313
```
Check feeds:
```bash
# Home feed:
curl -s http://localhost:1313/index.xml | head
# A section feed (adjust path to your section name):
curl -s http://localhost:1313/posts/index.xml | head
```
Check comments:
1. Open any post locally.
2. Scroll to the bottom — Giscus should appear.
3. Try a reaction or comment (youll be prompted to sign in with GitHub).
---
## 5) Per-post controls (handy later)
Turn comments off for a single post:
```toml
# in that posts front matter
comments = false
```
Full content in RSS (global):
```toml
[params]
ShowFullTextinRSS = true
```
Show RSS buttons on section/taxonomy lists:
```toml
[params]
ShowRssButtonInSectionTermList = true
```
---
## 6) Troubleshooting (aka “what I bumped into”)
- **Home page has no RSS icon**
Thats expected; either enable Home-Info/Profile mode + `socialIcons`, or use the footer partial.
- **Section feed 404**
Folder names are lowercase (`content/posts/`, not `content/Posts/`).
The URL mirrors that: `/posts/index.xml`.
- **Giscus doesnt create a discussion**
Double-check the four attributes: `data-repo`, `data-repo-id`, `data-category`, `data-category-id`.
Make sure Discussions is enabled and the Giscus app is installed for the repo.
- **Icon is comically large**
Keep the CSS above; 1620px usually looks right.
---
## 7) Bonus: fast setup via GitHub CLI (optional, I didn't use it, but I should've perhaps)
```bash
# Create the public comments repo (adjust names)
gh repo create yourname/blog-comments --public -d "Blog comment threads" --confirm
# Enable Discussions for it
gh repo edit yourname/blog-comments --enable-discussions
```
Then visit **giscus.app**, select the repo + category (e.g. “Comments”), and copy the generated IDs into `layouts/partials/comments.html`.
---
### Final config snapshot (condensed)
```toml
baseURL = "https://example.com/"
theme = "PaperMod"
[outputs]
home = ["HTML", "RSS", "JSON"]
section = ["HTML", "RSS"]
taxonomy = ["HTML", "RSS"]
term = ["HTML", "RSS"]
[services.rss]
limit = -1
[params]
comments = true
ShowRssButtonInSectionTermList = true
ShowFullTextinRSS = true
# Optional if using Home-Info/Profile mode:
# [[params.socialIcons]]
# name = "rss"
# url = "/index.xml"
```
---
# Appendix — Why I added Isso, how I wired it, and what I fixed
## The story (why Isso + why I still keep Giscus)
I wanted comments that:
- allow **anonymous** replies (no GitHub account required),
- are **lightweight** and **self-hosted**, and
- can work on my **Tor `.onion` mirror**.
**Isso** checks those boxes: its a tiny Python app with SQLite storage, no trackers, and a simple embed. At the same time, I like the developer-friendly workflow of **Giscus** (comments are GitHub Discussions), so I kept both and added a little switcher UI: **“Anonymous (Isso)”** or **“GitHub (Giscus)”**.
Result: readers can pick privacy/anon (Isso) or identity/notifications (Giscus).
## How I run it (high-level)
- **Site:** Hugo + PaperMod
- **Isso:** listening on `127.0.0.1:8080`, stored locally (SQLite)
- **Nginx:** reverse-proxies **`/isso/`** to Isso on both the clearnet and the `.onion` vhost
- **Hugo partial:** renders the two panels (Isso & Giscus) and a switcher; Giscus is **lazy-loaded** and **auto-rethemed** with the site theme
> Im deliberately keeping Issos own config tiny in this appendix; the important part for my setup is the **Nginx proxying** and the **Hugo partial**.
## Hugo partial: switcher + embeds
Save as `layouts/partials/comments.html` and include it in your single layout (e.g. `layouts/_default/single.html`) with `{{ partial "comments.html" . }}`.
```html
<!-- comments.html -->
<div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
<button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
<button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
<a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
</div>
<!-- Isso panel (default) -->
<div id="panel-isso">
<section id="isso-thread"></section>
<script
src="/isso/js/embed.min.js"
data-isso="/isso"
data-isso-css="true"
data-isso-lang="en"
data-isso-max-comments-nested="5"
data-isso-sorting="newest"
async>
</script>
<small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
</small>
</div>
<!-- Giscus panel (lazy-loaded on click) -->
<div id="panel-giscus" style="display:none">
<section id="giscus-thread"></section>
</div>
<script>
(function () {
const panelIsso = document.getElementById('panel-isso');
const panelGisc = document.getElementById('panel-giscus');
const issoBtn = document.getElementById('btn-isso');
const giscBtn = document.getElementById('btn-giscus');
let gLoaded = false;
function isDark() {
// 1) explicit PaperMod preference
const pref = localStorage.getItem('pref-theme');
if (pref === 'dark') return true;
if (pref === 'light') return false;
// 2) page state (classes / data-theme)
const H = document.documentElement, B = document.body;
const hasDark =
H.classList.contains('dark') || B.classList.contains('dark') ||
H.dataset.theme === 'dark' || B.dataset.theme === 'dark' ||
H.classList.contains('theme-dark') || B.classList.contains('theme-dark');
if (hasDark) return true;
// 3) OS preference
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const gTheme = () => (isDark() ? 'dark_dimmed' : 'noborder_light');
function loadGiscus() {
if (gLoaded) return;
const s = document.createElement('script');
s.src = 'https://giscus.app/client.js';
s.async = true;
s.crossOrigin = 'anonymous';
s.setAttribute('data-repo','AlipourIm/blog-comments');
s.setAttribute('data-repo-id','R_kgDOQGARyA');
s.setAttribute('data-category','Comments');
s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
s.setAttribute('data-mapping','pathname');
s.setAttribute('data-strict','0');
s.setAttribute('data-reactions-enabled','1');
s.setAttribute('data-emit-metadata','0');
s.setAttribute('data-input-position','bottom');
s.setAttribute('data-lang','en');
s.setAttribute('data-theme', gTheme()); // initial theme
document.getElementById('giscus-thread').appendChild(s);
// Retheme once iframe exists
const reTheme = () => {
const iframe = document.querySelector('iframe.giscus-frame');
if (!iframe) return;
iframe.contentWindow?.postMessage(
{ giscus: { setConfig: { theme: gTheme() } } },
'https://giscus.app'
);
};
// PaperMod toggle button
document.getElementById('theme-toggle')?.addEventListener('click', () =>
setTimeout(reTheme, 0)
);
// Also react to attribute changes
const mo = new MutationObserver(reTheme);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class','data-theme'] });
mo.observe(document.body, { attributes: true, attributeFilter: ['class','data-theme'] });
gLoaded = true;
}
function show(which) {
const showIsso = which === 'isso';
panelIsso.style.display = showIsso ? 'block' : 'none';
panelGisc.style.display = showIsso ? 'none' : 'block';
issoBtn?.setAttribute('aria-pressed', showIsso);
giscBtn?.setAttribute('aria-pressed', !showIsso);
if (!showIsso) loadGiscus();
}
issoBtn?.addEventListener('click', e => { e.preventDefault(); show('isso'); });
giscBtn?.addEventListener('click', e => { e.preventDefault(); show('giscus'); });
// default
show('isso');
})();
</script>
```
## Nginx: proxy Isso on **both** sites (and avoid the 404-onion trap)
The bug I hit: my `.onion` server had a regex static block that **won over** the plain `/isso/` location, so `/isso/js/embed.min.js` returned **404**.
**Fix:** make `/isso/` a `^~` prefix location so it beats regex, and place it **above** the static block.
```nginx
# /etc/nginx/sites-enabled/onion-blog (same idea for the clearnet vhost)
server {
listen 127.0.0.1:3301 default_server;
server_name fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion 127.0.0.1 localhost;
add_header Referrer-Policy no-referrer always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
root /srv/hugo/mysite/public-onion;
index index.html;
location / { try_files $uri $uri/ /index.html; }
# /isso -> /isso/
location = /isso { return 301 /isso/; }
# IMPORTANT: this must beat regex locations below
location ^~ /isso/ {
proxy_pass http://127.0.0.1:8080/; # Isso container/port
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_read_timeout 60s;
}
# static
location ~* \\.(css|js|ico|png|jpg|jpeg|gif|svg|webp|txt|xml)$ {
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}
```
Reload and smoke test:
```bash
sudo nginx -t && sudo systemctl reload nginx
curl -I http://<your-onion-host>/isso/js/embed.min.js # expect 200
```
> If you ever point `data-isso` to a **different origin**, add CORS headers on that vhost. For same-origin (`data-isso="/isso"`), you dont need CORS.
## Small CSS tweaks (one color across light/dark + spacing)
I wanted the same blue for **reply / edit / delete** links and for **Submit / Preview** buttons, in both themes, and a little spacing:
```css
/* put this in your site CSS */
:root { --btn-blue: #2563eb; } /* adjust to taste */
/* Isso form buttons */
.isso-post-action input[type="submit"],
.isso-post-action input[name="preview"] {
background: var(--btn-blue) !important;
color: #fff !important;
border: none;
padding: .5rem .75rem;
border-radius: .4rem;
cursor: pointer;
}
/* link actions under comments */
.isso-comment-footer a {
color: var(--btn-blue) !important;
margin-right: .5rem; /* gives "edit" and "delete" some breathing room */
text-decoration: none;
}
.isso-comment-footer a:hover { text-decoration: underline; }
/* keep name/email/website stacked, slightly larger */
.isso-auth-section .isso-input-wrapper {
display: block;
margin: .35rem 0;
}
.isso-auth-section label { font-size: .95rem; }
.isso-auth-section input[type="text"],
.isso-auth-section input[type="email"],
.isso-auth-section input[type="url"] {
width: 100%;
font-size: 1rem;
padding: .5rem .6rem;
}
```
## What changed (the fixes I actually made)
1. **Theme correctness for Giscus.**
PaperMod doesnt add `.light`—it only toggles `.dark` and stores `pref-theme`. I now:
- read `localStorage.pref-theme` first,
- fall back to class/data-theme, then
- fall back to OS preference.
I also re-theme the Giscus iframe on every toggle via `postMessage`.
2. **Isso over Tor.**
On the `.onion` vhost, I moved the proxy to a `^~ /isso/` block so it beats the static regex. That fixed the `404` on `/isso/js/embed.min.js`.
3. **UI polish.**
- Unified button/link color across themes,
- ensured “edit”/“delete” arent touching,
- kept the author/email/website inputs **stacked**, and
- left **Submit**/**Preview** styled and grouped.
## Sanity check
- Toggle the theme: Giscus switches between `noborder_light` and `dark_dimmed` without reload.
- Open DevTools → Network on onion: `/isso/js/embed.min.js` loads with **200**.
- Switcher buttons swap panels instantly; Giscus only loads when clicked.
## 8) Final notes!
I could not fully fix the style of Isso.
CSS is not easy... drat!
I don't think anyone actually reads the blog, let alone comment on it.
But it felt cool to have a comment section and also RSS for it.
I had a lot of not so fun debugging experience, and will probably work on the styling to make it look right, but for now I'm happy with it! ^^
# Admin note — Delete all **Isso** comments for a single post
This note is for future me: how to remove every comment tied to one post in **Isso**.
Tested with SQLite-backed Isso (default). Works whether you serve via clearnet or `.onion` — because this operates directly on the database.
---
## TL;DR (host with SQLite)
> Replace the placeholders for `DB` and `POST_URI` first.
```bash
# 0) Variables — edit these two lines for your setup
DB="/var/lib/isso/comments.db" # path to your Isso SQLite DB (check isso.conf: [general] dbpath)
POST_URI="/posts/skin_routine/" # the post's URI as stored by Isso (often just the path)
# 1) Backup (always do this!)
sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
# 2) Inspect: find the thread row and preview the comments
sqlite3 "$DB" "
.headers on
.mode column
SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
SELECT id, tid, created, author, text
FROM comments
WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%')
ORDER BY created DESC
LIMIT 25;
"
# 3) Delete all comments for that post (thread)
sqlite3 "$DB" "
DELETE FROM comments
WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');
"
# 4) (Optional) Remove the now-empty thread row too
sqlite3 "$DB" "
DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';
VACUUM;
"
# 5) Done. Isso usually picks this up automatically.
# If you're containerized and want to be safe:
# docker restart <isso_container_name>
```
---
## If running Isso in Docker
Open a shell in the container, then run the same steps:
```bash
docker exec -it <isso_container_name> /bin/sh
# Inside the container:
DB="/db/comments.db" # or /data/comments.db, check your image/volume mapping
POST_URI="/posts/skin_routine/"
sqlite3 "$DB" ".backup '/db/comments.$(date +%F-%H%M%S).backup.sqlite3'"
sqlite3 "$DB" "SELECT id, uri FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%';"
sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
exit
# Optionally restart the container:
docker restart <isso_container_name>
```
---
## Notes & gotchas
- **Find the real DB path**: In `isso.conf` (often `/etc/isso/isso.conf` or within your container at `/config/isso.conf`), look for:
```ini
[general]
dbpath = /var/lib/isso/comments.db
```
- **What goes in `POST_URI`?** Isso stores a `uri` string per thread. With the default embed config, it's usually the **path** (`/posts/<slug>/`). If youre unsure, list recent threads:
```bash
sqlite3 "$DB" ".headers on" ".mode column" "SELECT id, uri FROM threads ORDER BY id DESC LIMIT 50;"
```
- **Clearnet vs `.onion`**: If you load the same post under multiple origins _and_ your embed setup stores full origins, you may have **multiple thread rows**. The `LIKE` query above deliberately matches any uri containing your `POST_URI` to catch those.
- **Rollback**: To undo, stop Isso (optional), replace the DB with your backup (`comments.YYYY-MM-DD-HHMMSS.backup.sqlite3`), and start Isso again.
- **HTTP API option**: Isso supports `DELETE /<comment_id>` if admin endpoints/auth are enabled. For bulk removal its simpler and safer to use SQLite directly.
- **Safety**: Always run a backup first, and **preview with the SELECT** before you run the DELETE.
---
## One-liners for the impatient
```bash
DB="/var/lib/isso/comments.db"; POST_URI="/posts/skin_routine/"
sqlite3 "$DB" ".backup '$(dirname "$DB")/comments.$(date +%F-%H%M%S).backup.sqlite3'"
sqlite3 "$DB" "DELETE FROM comments WHERE tid IN (SELECT id FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%');"
sqlite3 "$DB" "DELETE FROM threads WHERE uri LIKE '%' || '$POST_URI' || '%'; VACUUM;"
```
---
**Isso container: quick checks & fixes for future me! (thank past me later ^^)**
**Find the container**
```bash
docker ps -a --format 'table {{.ID}} {{.Names}} {{.Status}} {{.Ports}}' | grep -i isso
```
**Inspect basic state**
```bash
docker inspect -f 'Name={{.Name}} Running={{.State.Running}} Status={{.State.Status}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' isso
```
**Logs**
```bash
docker logs --tail=200 isso
docker logs -f isso
```
**Ports & reachability**
```bash
docker port isso
sudo ss -ltnp | grep ':8080 '
curl -i http://127.0.0.1:8080/
```
**Restart policy (keep it running)**
```bash
docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' isso
docker update --restart unless-stopped isso
```
**Start/stop the container**
```bash
docker start isso
docker stop isso
```
**Who/what stopped it (events)**
```bash
docker events --since 24h --filter container=isso
```
**Common config issue: duplicate options/sections**
If logs show DuplicateOptionError or DuplicateSectionError, fix `/config/isso.cfg` (remove duplicate `[server]` blocks or duplicate `public-endpoint`).
```bash
nl -ba /config/isso.cfg | sed -n '1,120p'
grep -n '^\[server\]' /config/isso.cfg
grep -n '^public-endpoint' /config/isso.cfg
```
**Using Docker Compose** *(run these in the directory with your compose file)*
```bash
docker compose ps
docker compose logs --tail=200 isso
docker compose up -d isso
```
**If the container doesnt exist (create/recreate)**
```bash
docker image ls | grep -i isso
docker run -d --name isso -p 8080:8080 -v /srv/isso/config:/config -v /srv/isso/db:/db --restart unless-stopped ghcr.io/posativ/isso:latest
```
**Optional: exclude from auto-updaters (like Watchtower)**
```bash
docker update --label-add com.centurylinklabs.watchtower.enable=false isso
```
_This note is intentionally self-contained so I can paste it at the end of the blog post as a maintainer-only appendix._
---
{{< sigdl >}}