Why I wanted comments
I write blog posts about my homelab projects. People read them, some of them find them useful, and I know this because occasionally someone messages me on Linkedin. But there's no way for readers to leave feedback right there on the post. No "hey, I tried this and it worked" or "you missed a step here" or "this saved me three hours." That kind of feedback loop makes the posts better for everyone who comes after.
Ghost has built-in comments, but they require readers to create an account first. For a personal tech blog, that's a dealbreaker. Nobody is going to sign up for yet another account just to say "thanks, this helped." I needed something where you type your name, type your comment, and hit submit.
And lastly, because I can hehe. This is what I do I host my own services.
The options I considered
I spent some time looking at what's out there for comment systems that don't require reader accounts. Here's what I found:
| System | Free? | Ads? | Self-hosted? | Guest comments? |
|---|---|---|---|---|
| Disqus | Free tier has ads | Yes | No | Yes |
| giscus | Yes | No | No (GitHub hosted) | No (needs GitHub account) |
| Cusdis | Yes | No | Yes (Docker) | Yes |
| Isso | Yes | No | Yes (Docker) | Yes |
Disqus was out immediately. The free tier injects ads into your comment section, and the paid plans start at $11/month. I didn't escape cloud camera subscriptions just to pay for cloud comment subscriptions.
giscus is clever. It uses GitHub Discussions as the backend, so comments are stored in your repo's discussion threads. Clean UI, reactions, threading. But readers need a GitHub account to comment. Great for a developer tools blog, not great for a general audience.
Cusdis is the minimalist option. Self-hostable, no account required, just nickname and comment. But it's almost too minimal. No markdown, no voting, no threading, no edit/delete. It felt like a textarea with a submit button.
Isso hit the sweet spot. Self-hosted via Docker, anonymous comments with just a name, markdown support, upvote/downvote, threaded replies, and users can edit or delete their own comments within 15 minutes. It also has a built-in admin panel for moderation and uses SQLite, so there's no extra database to manage.
Setting it up
The architecture
My blog runs on Ghost at emir.fyi, served through Cloudflare -> cloudflared tunnel -> Caddy (HA load balancer) -> Ghost on Portainer (192.168.1.2). I needed to fit Isso into this without adding a new subdomain, new DNS records, new TLS certificates, or new Cloudflare tunnel configs.
The solution: serve Isso under a path on the same domain. Requests to emir.fyi/isso/* go to the Isso container, everything else still goes to Ghost. One Caddy rule, zero new infrastructure.
Docker Compose
Following the same pattern as my other Portainer services:
services:
isso:
image: ghcr.io/isso-comments/isso:release
container_name: isso
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /opt/isso/config:/config
- /opt/isso/db:/db
environment:
- TZ=America/New_York
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/info 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Nothing fancy. SQLite database and config mounted as bind mounts to /opt/isso/ on the host. The release tag pins to the latest stable version.
Isso configuration
[general]
dbpath = /db/comments.db
host =
https://emir.fyi
max-age = 15m
gravatar = true
[server]
listen = http://0.0.0.0:8080
public-endpoint = https://emir.fyi/isso
[moderation]
enabled = false
purge-after = 30d
[guard]
enabled = true
ratelimit = 2
direct-reply = 3
reply-to-self = false
require-author = true
require-email = false
[markup]
options = strikethrough, superscript, autolink
[admin]
enabled = true
password = your_strong_password_here
A few things worth noting:
hostmust match your blog's URL exactly. Isso uses this for CORS, so if it doesn't match, comments will fail silently.public-endpointtells Isso where it lives from the outside. This is the path that gets embedded in the comment widget.moderation = falsebecause I want comments to post immediately. Life's too short to moderate a personal blog.guardis still on. Rate limiting (2 comments per minute per IP) and requiring an author name keeps the low-effort spam out.require-email = falsebecause I don't want to create friction. Name and comment, that's it.
Caddy route
The only infrastructure change was adding a path handler to the existing :80 block in the Caddyfile. This is the block that Cloudflare's tunnel hits for emir.fyi:
:80 {
handle_path /isso/* {
reverse_proxy 192.168.1.2:8080
}
handle {
reverse_proxy 192.168.1.2:2368 {
header_up X-Forwarded-Proto https
}
}
}
handle_path strips the /isso/ prefix before forwarding to the Isso container, so Isso sees clean paths. Everything else falls through to Ghost. Deployed via the existing Ansible playbook across all three HA load balancer nodes.
Ghost theme integration
The last piece is embedding the Isso widget in blog posts. I edited the Attila theme's post.hbs to include the Isso script:
<section class="post-comments">
<div id="isso-thread"></div>
<script data-isso="https://emir.fyi/isso/"
src="https://emir.fyi/isso/js/embed.min.js"></script>
</section>
This goes right before the existing {{#if comments}} block. Ghost needs a restart to pick up the theme change.
Dark mode
Isso's default styles assume a white background, which looks fine on light mode but terrible on dark mode. White input fields and invisible text on a dark page. Since my theme (Attila) toggles dark mode via a theme-dark class on the HTML element, I added CSS through Ghost's code injection (Settings -> Code injection -> Site Header):
.theme-dark #isso-thread .isso-postbox .isso-textarea,
.theme-dark #isso-thread .isso-postbox input[type="text"] {
background-color: #2a2a2a;
color: #e0e0e0;
border-color: #555;
}
.theme-dark #isso-thread .isso-postbox input[type="submit"],
.theme-dark #isso-thread .isso-postbox input[name="preview"] {
background-color: #444;
color: #e0e0e0;
border-color: #555;
}
.theme-dark #isso-thread .isso-comment .isso-text,
.theme-dark #isso-thread .isso-comment .isso-comment-header {
color: #e0e0e0;
}
.theme-dark #isso-thread h4 {
color: #ccc;
}
.theme-dark #isso-thread a {
color: #6cb4ff;
}
That last rule is important. Isso renders Edit/Delete links in default blue, which is invisible on a dark background. Light blue (#6cb4ff) fixes that.
The website field validation problem
Isso has an optional Website field where commenters can link their site. The problem is that Isso's server validates it strictly: if you type example.com without https://, it returns a 400 Bad Request with zero feedback in the UI. The comment just doesn't post and you have no idea why.
I couldn't change Isso's server-side validation, so I added client-side validation via Ghost's code injection (Site Footer) that highlights the field and updates the label when the URL format is wrong:
document.addEventListener('DOMContentLoaded', function() {
var observer = new MutationObserver(function() {
var website = document.querySelector('#isso-postbox-website');
if (website && !website.dataset.validated) {
website.dataset.validated = 'true';
var label = document.querySelector('label[for="isso-postbox-website"]');
var originalText = label.textContent;
website.addEventListener('input', function() {
var val = website.value.trim();
if (val === '' || /^https?:\/\/.+/.test(val)) {
website.style.borderColor = '';
label.textContent = originalText;
} else {
website.style.borderColor = 'red';
label.textContent = originalText + ' - invalid';
}
});
}
});
observer.observe(document.body, {childList: true, subtree: true});
});
A MutationObserver because Isso's widget loads asynchronously into an iframe/div after the page renders. The validation fires on every keystroke: empty is fine (it's optional), anything starting with http:// or https:// is fine, anything else turns the border red and appends " - invalid" to the label. The form still submits either way, but at least the user can see something is wrong before they wonder why their comment disappeared.
Security considerations
Since Isso is publicly accessible at emir.fyi/isso/, I thought about what's exposed:
- Admin panel at
/isso/admin/is password-protected with a strong password. No rate limiting on login attempts though, so the password matters. - CORS restricts the comment API to requests from
emir.fyionly. You can't post comments from a different origin. - Rate limiting via the guard config prevents comment flooding (2 per minute per IP subnet).
- No reCAPTCHA or CAPTCHA support. Isso doesn't offer it. For a personal blog, Cloudflare's bot protection plus rate limiting should be enough. If spam becomes a problem, I'll revisit.
The container itself is minimal. It runs as an unprivileged user, stores data in SQLite (no exposed database ports), and the only network exposure is port 8080 proxied through Caddy.
What it looks like
Comment form at the bottom of every post. Type your name, optionally your email (for Gravatar only, not displayed), optionally a website, write your comment in markdown, hit Submit. It posts immediately. You get 15 minutes to edit or delete your own comment. Upvote and downvote. Threaded replies.
No account creation. No OAuth flow. No "sign in with Google." Just a comment box that works.
What I'd do differently
If I were starting fresh, I'd look harder at whether Isso is still actively maintained. The last release was 0.13.0 and the GitHub activity is sporadic. It works well today, but I'm mentally prepared to swap it out if it stops getting updates. The data is in a single SQLite file, so migration would be straightforward.
I'd also consider running Isso behind its own subdomain from the start if I had wildcard DNS and certs already set up. The path-based routing works fine, but a subdomain would be cleaner and wouldn't require the handle_path prefix stripping in Caddy.
But for a weekend project that adds real community interaction to a blog? Isso does exactly what I needed. No SaaS dependency, no ads, no reader accounts, and it runs on infrastructure I already had.