Comments on static sites with Isso — lightweight, self-hosted, and tracking-free

In this post
A static site has no backend. No database, no application server processing requests — and that’s exactly what makes it fast, cheap, and resilient. But that simplicity comes at a cost when you need anything that depends on persistent state, and comments are the most obvious case. In WordPress or Ghost, the commenting system is part of the application. In a site generated by Hugo, Jekyll, or Eleventy, that layer simply doesn’t exist.
The solution that dominated for over a decade was Disqus: a JavaScript snippet, an iframe with a comment box, and you’re done. The real cost of that convenience hides in the fine print — tracking scripts, third-party cookies, ads injected into your site, and all data stored on servers you don’t control. There are alternatives based on GitHub Issues (Utterances, Giscus), which work well for technical blogs but require readers to have a GitHub account. And there’s a category that solves the problem in the most direct way: a lightweight comment server that you host yourself, with your own data, on your own domain. Isso fits that category.
What is Isso#
Isso is an open-source comment server written in Python, created in 2012 as a self-hosted alternative to Disqus. The name comes from the German Ich schrei sonst — roughly “or else I scream”. The architecture is deliberately simple: the server is a Python application that exposes a REST API and stores everything in a single SQLite file. The client is a JavaScript file of roughly 20 KB (gzipped) that you embed in your pages. There’s no elaborate admin panel, no account system, no social media integration. Visitors write comments providing a name and email — optionally anonymous — and can edit or delete what they wrote within a configurable time window.
Comments support Markdown, threading works natively with nested replies, and moderation can be enabled so that comments only appear after approval. Isso also includes an import tool that reads XML dumps from Disqus and WordPress.
The choice of SQLite as a backend is a design decision, not a limitation. Blog comments are small data, with sporadic writes and a massive read-to-write ratio. SQLite handles that profile effortlessly, and backing up your entire comment history comes down to copying a single file.
Architecture#
The setup we’re going to build works like this: your blogs stay hosted on Cloudflare Pages (or any other static hosting service), and Isso runs on a separate VPS, accessible via a subdomain like isso.yourdomain.org. When a visitor opens a post, the Isso JavaScript loads from the VPS, fetches the comments for that page, and renders the comment section. If the VPS goes down for any reason, the blog keeps working normally — the visitor just won’t see comments until the service comes back. The failure is graceful.
Isso listens on a local port and doesn’t implement TLS. A reverse proxy — Nginx, Caddy, or whatever you already have — handles HTTPS. Without HTTPS on the Isso endpoint, comments won’t work on any site served via HTTPS, because the browser silently refuses mixed-content connections.
Preparing the server#
This tutorial uses a VPS running Ubuntu 24.04 with Nginx managed by WordOps. If you have a different Nginx setup, adapt the reverse proxy section. Everything else is identical.
Install the dependencies:
apt update
apt install python3-dev python3-venv build-essential
Create the directory structure. In this example, everything lives under /var/www/isso.yourdomain.org — configuration, database, and virtualenv in the same place, which simplifies backups:
mkdir -p /var/www/isso.yourdomain.org/{config,db}
Create the virtualenv and install Isso and gunicorn:
python3 -m venv /var/www/isso.yourdomain.org/venv
/var/www/isso.yourdomain.org/venv/bin/pip install isso gunicorn
Set permissions for the web server user (on WordOps and many Nginx setups, that’s www-data):
chown -R www-data:www-data /var/www/isso.yourdomain.org
Configuration#
Isso uses INI-format configuration files. If you’re serving comments for a single site, one file is enough. To serve multiple sites from the same instance — which is our case, with two blogs — the approach changes: each site needs its own configuration file with a name field that identifies it, and Isso runs via gunicorn with the isso.dispatch module instead of the isso run command directly.
The official documentation is explicit on this point: the host parameter in a single config accepts multiple URLs, but only variants of the same site (for example, with and without www, or HTTP and HTTPS). Different sites require separate configs.
Create the file for the first site, /var/www/isso.yourdomain.org/config/blog1.cfg:
[general]
name = blog1
dbpath = /var/www/isso.yourdomain.org/db/blog1.db
host = https://blog1.yourdomain.org
max-age = 15m
notify = stdout
[moderation]
enabled = true
[server]
listen = http://127.0.0.1:8000
[guard]
enabled = true
ratelimit = 2
direct-reply = 3
reply-to-self = false
require-author = true
require-email = true
[admin]
enabled = true
password = CHANGE_TO_A_STRONG_PASSWORD
And the file for the second site, /var/www/isso.yourdomain.org/config/blog2.cfg:
[general]
name = blog2
dbpath = /var/www/isso.yourdomain.org/db/blog2.db
host = https://blog2.yourdomain.org
max-age = 15m
notify = stdout
[moderation]
enabled = true
[server]
listen = http://127.0.0.1:8000
[guard]
enabled = true
ratelimit = 2
direct-reply = 3
reply-to-self = false
require-author = true
require-email = true
[admin]
enabled = true
password = CHANGE_TO_A_STRONG_PASSWORD
The key points:
- name — unique identifier for the site. This value defines the subpath in the API URL (
/blog1/,/blog2/). - dbpath — absolute path. Each site gets its own SQLite database.
- host — the URL of your blog, not of Isso. Used for CORS validation.
- moderation enabled = true — every comment goes through approval before appearing. Disable it once you’re comfortable with the guard settings and your readership.
- require-author and require-email = true — significantly reduces anonymous spam.
- admin enabled = true — enables the moderation panel, accessible at
https://isso.yourdomain.org/blog1/admin.
If you’re serving a single site, you can use one file and run with isso -c /path/to/config.cfg run directly. In that case, the name field isn’t needed and the API endpoints live at the root (no subpath).
Running with gunicorn and systemd#
For multi-site setups, Isso uses the isso.dispatch module with gunicorn. Gunicorn needs to know which configuration files to load through the ISSO_SETTINGS environment variable, with paths separated by semicolons.
Test manually before creating the service:
sudo -u www-data \
ISSO_SETTINGS="/var/www/isso.yourdomain.org/config/blog1.cfg;/var/www/isso.yourdomain.org/config/blog2.cfg" \
/var/www/isso.yourdomain.org/venv/bin/gunicorn isso.dispatch -b 127.0.0.1:8000
You should see in the logs that gunicorn started and both sites were connected. In another terminal, confirm:
curl http://127.0.0.1:8000/blog1/info
curl http://127.0.0.1:8000/blog2/info
Each should return a JSON with the Isso version, the configured origin, and the moderation status. If both respond, Ctrl+C the gunicorn process and create the systemd service.
Create /etc/systemd/system/isso.service:
[Unit]
Description=Isso Commenting Server
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
Environment=ISSO_SETTINGS=/var/www/isso.yourdomain.org/config/blog1.cfg;/var/www/isso.yourdomain.org/config/blog2.cfg
ExecStart=/var/www/isso.yourdomain.org/venv/bin/gunicorn isso.dispatch -b 127.0.0.1:8000
WorkingDirectory=/var/www/isso.yourdomain.org
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Enable and start:
systemctl daemon-reload
systemctl enable --now isso
systemctl status isso
From this point on, Isso starts automatically with the server.
Reverse proxy with Nginx#
Isso needs to be accessible via HTTPS so that visitors’ browsers can communicate with the API. The process listens on 127.0.0.1:8000 and Nginx handles the proxy.
If you use WordOps, the command is straightforward:
wo site create isso.yourdomain.org --proxy=127.0.0.1:8000 -le
This creates the vhost, configures the reverse proxy, and provisions the Let’s Encrypt certificate automatically.
If you don’t use WordOps, create the vhost manually. A minimal example for /etc/nginx/sites-available/isso.yourdomain.org:
server {
listen 80;
listen [::]:80;
server_name isso.yourdomain.org;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable the site and obtain the SSL certificate:
ln -s /etc/nginx/sites-available/isso.yourdomain.org /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d isso.yourdomain.org
Test from outside the server:
curl https://isso.yourdomain.org/blog1/info
curl https://isso.yourdomain.org/blog2/info
If they return JSONs with the version and origin of each site, the server is ready.
Why not Docker#
The official Isso image (ghcr.io/isso-comments/isso:release) ships with gunicorn hardcoded to port 8080 in the entrypoint. The listen directive in the configuration file and the GUNICORN_CMD_ARGS environment variable are both ignored — gunicorn always tries to bind to port 8080, regardless of what you configure.
With network_mode: host, if port 8080 is already in use on the host by another service (a control panel, for instance), the container enters an error loop and never starts. With bridge networking and port mapping (-p 127.0.0.1:OTHER_PORT:8080), the Docker proxy should translate ports, but in some environments the NAT doesn’t work correctly — the connection establishes and is immediately reset, even though Isso responds normally from inside the container.
The native installation with virtualenv, gunicorn, and systemd eliminates all those intermediate layers. You control the port directly, without depending on the image’s entrypoint, the Docker proxy, or Docker’s iptables/nftables rules. For a lightweight Python application with a SQLite database, Docker’s complexity doesn’t pay for itself.
Hugo integration#
With the server running and accessible via HTTPS, the Hugo side comes down to two things: including the Isso script in the post template and — if you use a CMS like Pages CMS — adding the corresponding field to the CMS configuration so you can enable or disable comments per post.
The comments partial#
Create the file layouts/partials/comments.html in your blog’s repository:
<section id="isso-comments">
<script
data-isso="https://isso.yourdomain.org/blog1/"
data-isso-css="true"
data-isso-lang="en"
data-isso-reply-to-self="false"
data-isso-require-author="true"
data-isso-require-email="true"
data-isso-avatar="true"
data-isso-avatar-bg="#f0f0f0"
data-isso-vote="true"
src="https://isso.yourdomain.org/blog1/js/embed.min.js"
></script>
<noscript>Please enable JavaScript to view comments.</noscript>
<section id="isso-thread"></section>
</section>
The data-isso value is the base URL of your Isso instance, including the site’s subpath (/blog1/). The src points to the script served by the Isso instance itself. The data-isso-* attributes control client behavior — the full reference is in the official documentation.
Including in the template#
How you include the partial depends on your theme. Some Hugo themes have native Isso support and expose a front matter field to enable it — check your theme’s documentation to find out whether that’s the case and what the expected field name is (it could be comments, isso, disableComments, among others). In those cases, just configure the parameters in hugo.toml and the theme handles the rest.
If the theme doesn’t have native support, edit the post template (layouts/_default/single.html or the equivalent in your theme) and add the partial call where comments should appear — usually after the post content and before the footer:
{{ if ne .Params.comments false }}
{{ partial "comments.html" . }}
{{ end }}
With this logic, comments appear on all posts by default. To disable them on a specific post, add comments: false to the front matter.
Pages CMS configuration#
If you manage content through Pages CMS, add the comments field to .pages.yml so it shows up in the editing interface. The field name must match what the template expects — in the example above, it’s comments:
- name: comments
label: Comments
type: boolean
default: true
This lets you enable or disable comments per post directly from the CMS interface, without editing Markdown manually.
Moderation and notifications#
With moderation = true in the configuration, every new comment enters a queue and stays invisible until you approve it. Approval can be done through the admin panel (accessible at https://isso.yourdomain.org/blog1/admin) or via signed links sent by email.
To receive email notifications, change notify = stdout to notify = smtp and configure the [smtp] section:
[general]
notify = smtp
[smtp]
username = your@email.com
password = your-password-or-app-password
host = smtp.yourprovider.com
port = 587
security = starttls
to = your@email.com
from = isso@yourdomain.org
timeout = 10
One thing worth noting: Isso sends emails via SMTP from the server where it’s running. If the sender domain doesn’t have SPF, DKIM, and rDNS records properly configured, those emails are likely to land in spam. If you don’t want to deal with IP reputation and email configuration, point the SMTP to a relay service like Mailgun, Brevo, or Gmail with an app password.
Backup#
The database is a single SQLite file per site. If that file gets corrupted or deleted, you lose your entire comment history. The solution is a cron job that copies the files periodically:
sqlite3 /var/www/isso.yourdomain.org/db/blog1.db ".backup /backups/isso-blog1-$(date +%Y%m%d).db"
sqlite3 /var/www/isso.yourdomain.org/db/blog2.db ".backup /backups/isso-blog2-$(date +%Y%m%d).db"
SQLite’s .backup command copies while the database is live, without inconsistencies even if Isso is writing at that moment. The file rarely exceeds a few megabytes, so copying to external storage with rsync or rclone is trivial.
If you organized everything under /var/www/isso.yourdomain.org/ as we did here, and your server already has a backup script that sweeps /var/www/, the databases are already covered without any additional configuration.
Migrating from Disqus or WordPress#
If you already have comments in another system, Isso includes an import tool:
/var/www/isso.yourdomain.org/venv/bin/isso -c /var/www/isso.yourdomain.org/config/blog1.cfg import -t disqus disqus-export.xml
/var/www/isso.yourdomain.org/venv/bin/isso -c /var/www/isso.yourdomain.org/config/blog1.cfg import -t wordpress wordpress-export.xml
Comments are associated with the original page URIs. If the URL structure changed when migrating to Hugo, imported comments will point to the old paths. The fix is straightforward in SQLite:
sqlite3 /var/www/isso.yourdomain.org/db/blog1.db \
"UPDATE threads SET uri = '/new/path/' WHERE uri = '/old/path/';"
Conclusion#
Isso does exactly what it promises — self-hosted, lightweight comments with no tracking — without trying to do more than it should. The native installation with virtualenv and gunicorn is simple, predictable, and uses minimal resources. The database is a file you can open with sqlite3, comments are plain text with Markdown, and if the project is ever abandoned, your data remains readable without any special tooling.
For anyone who already runs a server and wants full control over their visitors’ data, it’s the choice that makes the most sense.
Sysadmin, 53, Brazilian working from home for the world. Manages Linux servers, LXC containers, and cats that won't get off the keyboard.