Self-Host Blog Comments with Remark42 (Privacy-First)

Most blogs reach for Disqus on day one because it takes about five minutes to set up. What you don’t see at sign-up is the deal you’re making: Disqus is free because it monetizes your readers. Every person who loads your comment section gets tracked, profiled, and served ads — not because they agreed to it, but because that’s the business model behind the embed script you pasted into your template.
Remark42 changes the equation entirely. It is a self-hosted, open-source comment engine built in Go, distributed as a single Docker image, and designed from the ground up to collect the minimum data necessary to run a comment section — nothing more. This guide walks through everything from prerequisites to production: deploying Remark42 behind Nginx with HTTPS, integrating it into a Hugo site, configuring moderation, and keeping your data safe with automated backups.
Why Third-Party Comment Systems Are a Privacy Problem
The scale of what Disqus embeds on your page is stunning once you look. Independent web performance audits document over 90 third-party requests triggered by a single Disqus widget load. Those requests add between 300 and 500 milliseconds to your page’s time-to-interactive on a typical broadband connection, and considerably more on mobile. Your visitors’ reading habits, scroll depth, browsing history via cross-site cookies, and identity are assembled into a commercial profile that Disqus sells to advertisers. Your readers never consented to any of this — they came to read your blog post.
The legal exposure is also real. If any portion of your readership is in the European Union, embedding Disqus without a valid Data Processing Agreement and explicit consent flow puts you in violation of the GDPR. The “it’s just a comment widget” defense has not aged well since enforcement began; supervisory authorities in Germany and Austria have taken action against sites for similar third-party integrations.
Remark42 collects only what is strictly necessary: the text of comments, an anonymized OAuth identity token derived from the provider’s user ID, optional vote counts, and optional email addresses for notification purposes. It sets no cross-site tracking cookies. It contains no ad network code. The entire dataset lives in a single BoltDB file on your server, and you can delete it entirely at any time without coordinating with a third party.
Prerequisites
Before you start, make sure the following are in place:
- A Linux server (VPS or bare metal) with Docker and Docker Compose installed. Any recent Ubuntu LTS, Debian, or Fedora release works fine.
- A domain name pointing to your server’s public IP. Remark42 requires a real domain for its OAuth callback URLs; it cannot run under a bare IP address in production mode.
- Nginx installed and running as your site’s reverse proxy. This guide uses Nginx for SSL termination. Caddy users can adapt the configuration with minimal changes.
- Certbot for Let’s Encrypt certificate management, or an existing wildcard certificate already in place. Installation instructions for all platforms are at certbot.eff.org .
- A GitHub account for OAuth app registration — the most common starting point for technical blogs. Google, Microsoft, and GitLab are equally valid if you prefer them.
Remark42 Architecture Overview
Remark42 is intentionally simple to operate. The entire backend is a single statically-compiled Go binary packaged inside the umputun/remark42 Docker image. There is no external database server, no Redis instance, no message queue — just the binary and one BoltDB file on disk.
Authentication is handled exclusively through OAuth providers: GitHub, Google, Facebook, Microsoft, Telegram, and GitLab are supported out of the box. Remark42 never stores passwords. When a user authenticates, the provider sends back a user ID and display name; Remark42 hashes the provider-scoped user ID to create a stable anonymous identity token. Email addresses are optional and only stored if the user explicitly supplies one for notification purposes.
The frontend is a lightweight JavaScript widget — roughly 40 KB compressed — that you embed in your site’s HTML. It talks to the Remark42 REST API over HTTPS, renders comments in a shadow DOM element to prevent CSS bleed from your theme, and handles vote submission and reply threading entirely client-side before syncing with the server.
Data persistence is a single .db file managed by BoltDB, an embedded key-value store. For a personal blog with thousands of comments this is perfectly adequate; BoltDB handles tens of thousands of concurrent reads without issue. The practical bottleneck at scale is write throughput — BoltDB uses a single write lock — but a blog would need to be receiving hundreds of comments per second before that becomes relevant.
Registering a GitHub OAuth Application
The most common authentication provider for self-hosted blogs is GitHub, since the audience is often technical and already has accounts. Registering an OAuth app takes about two minutes.
- Go to GitHub Developer Settings and click New OAuth App.
- Fill in the fields:
- Application name: anything descriptive, e.g. “My Blog Comments”
- Homepage URL:
https://yourdomain.com - Authorization callback URL:
https://comments.yourdomain.com/auth/github/callback
- Click Register application.
- On the next screen, copy the Client ID. Then click Generate a new client secret and copy that value immediately — GitHub only shows it once.
You will set AUTH_GITHUB_CID and AUTH_GITHUB_CSEC in the Docker Compose environment using these values. The pattern is identical for Google (via Google Cloud Console) and Microsoft (via Azure App Registrations) if you want to offer multiple sign-in options.
Deploying Remark42 with Docker Compose
Create a directory for your Remark42 deployment — /opt/remark42 is a sensible choice — and place the following docker-compose.yml inside it:
version: "3.8"
services:
remark42:
image: umputun/remark42:latest
container_name: remark42
restart: always
environment:
- REMARK_URL=https://comments.yourdomain.com # public base URL
- SITE=yourblog # arbitrary site ID used in the embed snippet
- SECRET=change-me-to-a-long-random-string # JWT signing secret (use openssl rand -hex 32)
- AUTH_GITHUB_CID=your_github_client_id
- AUTH_GITHUB_CSEC=your_github_client_secret
- ADMIN_SHARED_ID=github_yourgithubusername # promotes this account to admin
- BACKUP_PATH=/srv/var/backup # where backup JSON files go
- MAX_BACKUP_COUNT=30 # keep 30 daily backups
- NOTIFY_EMAIL_FROM=remark42@yourdomain.com
- SMTP_HOST=smtp.yourdomain.com
- SMTP_PORT=587
- SMTP_USERNAME=remark42@yourdomain.com
- SMTP_PASSWORD=your_smtp_password
- SMTP_TLS=true
volumes:
- ./data:/srv/var/data # BoltDB file lives here
- ./backup:/srv/var/backup # backup JSON exports
ports:
- "127.0.0.1:8080:8080" # bind to loopback only; Nginx fronts it
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/ping"]
interval: 30s
timeout: 10s
retries: 3A few notes on the key variables:
REMARK_URLmust match the public URL exactly, including scheme, because it is embedded in OAuth callback redirects. A mismatch causes authentication loops.SECRETshould be at least 32 characters of random ASCII. Generate one withopenssl rand -hex 32.ADMIN_SHARED_IDuses the formatprovider_provideruserid. For GitHub, this isgithub_yourgithubusername(your GitHub username, all lowercase). Log in through the widget first, then check the container logs to confirm your exact token.BACKUP_PATHandMAX_BACKUP_COUNTactivate the built-in scheduled backup — more on this in the maintenance section.
Start the container and watch its output:
docker compose up -d
docker compose logs -f remark42Watch for a line confirming it is listening on :8080. The health check endpoint /api/v1/ping returns {"status": "ok"} when the service is ready.
Configuring Nginx as a Reverse Proxy
Remark42 binds to 127.0.0.1:8080 and is not exposed directly to the internet. Nginx handles TLS termination and proxies requests through. Create the following server block at /etc/nginx/sites-available/remark42:
server {
listen 80;
server_name comments.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name comments.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/comments.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/comments.yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $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;
# WebSocket support (used by Remark42 for live comment updates)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 90s;
}
}Enable the site and obtain the Let’s Encrypt certificate:
sudo ln -s /etc/nginx/sites-available/remark42 /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d comments.yourdomain.comCertbot will automatically insert the certificate paths into your server block and set up a renewal cron job. After this step, https://comments.yourdomain.com/api/v1/ping should return {"status":"ok"}.
Integrating Remark42 with Hugo
Hugo uses partial templates to render comment sections. Create a new file at layouts/partials/comments.html in your Hugo project root — not inside the theme, since your project’s layouts/ folder takes precedence over the theme’s:
{{- if and .IsPage (not .Params.nocomments) -}}
<div id="remark42"></div>
<script>
var remark_config = {
host: "https://comments.yourdomain.com",
site_id: "yourblog",
components: ["embed"],
max_shown_comments: 20,
theme: "light",
locale: "en",
};
(function(c) {
for(var i = 0; i < c.length; i++) {
var d = document, s = d.createElement('script');
s.src = remark_config.host + '/web/' + c[i] + '.js';
s.defer = true;
(d.head || d.body).appendChild(s);
}
})(remark_config.components || ['embed']);
</script>
{{- end -}}The {{- if and .IsPage (not .Params.nocomments) -}} guard ensures the widget only loads on actual post pages, not on taxonomy pages, the home page, or section list pages. It also respects a per-post opt-out flag. To disable comments on a specific post, add nocomments: true to its front matter:
---
title: "A Post With No Comments"
nocomments: true
---To invoke this partial, your theme needs to call it somewhere in the single post layout. With LoveIt, the cleanest approach is to create a layouts/_default/single.html override that includes:
{{ partial "comments.html" . }}If your Hugo theme supports a dark mode toggle, you can wire it to the Remark42 theme by calling window.REMARK42.changeTheme('dark') or window.REMARK42.changeTheme('light') from your theme-switching JavaScript.
Moderation, Spam Control, and Administration
Once you authenticate through the comment widget with the account you listed in ADMIN_SHARED_ID, you gain access to the admin interface at https://comments.yourdomain.com/web. From here you can pin comments, delete individual messages, block users by their anonymized identity token, and switch the site between open mode (comments appear immediately) and read-only mode (new comments require admin approval before becoming visible).
For email notifications — so you know when someone leaves a comment without checking the dashboard — configure the SMTP variables in your Docker Compose file as shown above. Remark42 will send you an email for each new comment with a direct link to approve or delete it. This is particularly useful in the first few weeks after enabling comments on a post that gets shared widely.
Toxic content detection is available through integration with the Perspective API
, Google’s ML-based toxicity scoring service. After registering for an API key, set POSITIVE_SCORE_THRESHOLD=0.5 in your environment variables. Comments that score above the threshold are held for manual review rather than published automatically, giving you a lightweight spam filter without running any local ML infrastructure.
For a personal blog with a small audience, the built-in moderation workflow — email notification plus the admin web panel — is more than sufficient. Perspective integration makes sense once your posts start receiving significant traffic from social media shares.
Remark42 vs. Other Self-Hosted Comment Engines
Before committing to Remark42, it is worth knowing how it compares to the other viable self-hosted options:
| Feature | Remark42 | Commento | Isso | Giscus |
|---|---|---|---|---|
| Language / runtime | Go (single binary) | Go + PostgreSQL | Python + SQLite | JavaScript (GitHub Discussions API) |
| Database | Embedded BoltDB | PostgreSQL required | SQLite | None (GitHub-hosted) |
| Authentication | OAuth (6 providers) | OAuth / email | Anonymous / email | GitHub account only |
| Docker image size | ~30 MB | ~150 MB | ~80 MB | N/A (hosted service) |
| Admin UI | Yes (built-in) | Yes | Limited | Via GitHub web UI |
| Anonymous comments | Yes | Optional | Yes | No |
| Data ownership | Full | Full | Full | Partial (GitHub) |
| Best for | Privacy-first blogs | Teams needing email auth | Minimal setups | Developer blogs on GitHub |
Commento requires PostgreSQL, which adds operational overhead for a single-person blog. Isso is the simplest option in terms of setup but lacks OAuth and has a less polished UI. Giscus is a hosted service backed by GitHub Discussions — zero operational overhead, but it ties your comment data to GitHub’s infrastructure and requires readers to have a GitHub account to participate.
Remark42 hits the sweet spot for most self-hosted bloggers: single container, embedded database, real OAuth authentication, and a capable admin interface, without requiring you to manage a separate database server.
Performance Considerations
BoltDB uses a B-tree data structure with MVCC (Multi-Version Concurrency Control) for reads and a single write lock for mutations. In practice this means Remark42 supports a large number of concurrent readers with no lock contention, while writes are serialized. For a blog post that suddenly goes viral and receives 500 comments in an hour, the write queue processes sequentially but without dropping requests. The Remark42 project’s own benchmarks show the service handling approximately 1,000 requests per second on modest hardware (2 vCPUs, 2 GB RAM).
If your blog grows to the point where BoltDB becomes a concern — which for most personal blogs means tens of millions of comments — Remark42 supports a PostgreSQL backend as an alternative storage layer. The switch requires an environment variable change and a one-time data migration, both of which are documented in the project’s GitHub repository. For the overwhelming majority of personal blog deployments, BoltDB on a $6/month VPS is more than sufficient for the lifetime of the site.
Backup, Migration, and Maintenance
Self-hosting means you own the data-recovery responsibility. Remark42 makes this straightforward in several ways.
Automated backups are configured through the BACKUP_PATH and MAX_BACKUP_COUNT environment variables already shown in the Docker Compose file above. Remark42 exports the entire BoltDB to a timestamped JSON file daily by default and rotates old files once the count exceeds MAX_BACKUP_COUNT. These JSON exports are human-readable, making them useful for both disaster recovery and data portability.
To move backups off your Docker host, add an rclone cron job that syncs the backup directory to Backblaze B2, AWS S3, or any S3-compatible object store:
# /etc/cron.d/remark42-backup
0 3 * * * root rclone sync /opt/remark42/backup b2:your-bucket/remark42-backups \
--log-file=/var/log/rclone-remark42.logMigrating from Disqus is officially supported. Export your Disqus comment archive from the Disqus dashboard under Settings then Export, and import the resulting XML file using the Remark42 REST API import endpoint:
curl -X POST \
-H "Content-Type: application/xml" \
-H "X-CSRF-Token: $(cat admin-token.txt)" \
--data-binary @disqus-export.xml \
"https://comments.yourdomain.com/api/v1/admin/import?site=yourblog&provider=disqus"Thread mapping happens by matching the Disqus <thread link> URL to the url parameter Remark42 stores per comment thread. As long as your blog post URLs have not changed since the Disqus era, the import is fully automatic.
Upgrading Remark42 is a three-step process. Pull the new image, stop the container, restart it. The embedded migration system handles any BoltDB schema changes automatically on first startup:
cd /opt/remark42
docker compose pull
docker compose down
docker compose up -d
docker compose logs -f remark42Watch the startup logs to confirm the migration completed successfully before routing traffic back. Because Remark42 creates a JSON backup before applying schema migrations, you have a clean recovery point if anything goes wrong.
Finding Your Admin Identity Token
One detail that trips up many first-time administrators: the ADMIN_SHARED_ID value is not simply your GitHub username. It is the provider-prefixed identity token that Remark42 derives from your OAuth provider’s user ID. The safest way to find it is to start Remark42 without setting ADMIN_SHARED_ID, open your site’s comment section, sign in with your GitHub account, then check the container logs:
docker compose logs remark42 | grep "login"You will see a line containing something like "id":"github_12345678". Copy that token and set ADMIN_SHARED_ID=github_12345678 in your Docker Compose file, then restart the container. After restarting, your account will display an admin badge in the comment widget and you will have access to the web admin panel at https://comments.yourdomain.com/web.
Wrapping Up
Remark42 gives you a genuinely usable comment system — OAuth authentication, threaded replies, vote counts, moderation tools, email notifications — without selling your readers’ attention to advertisers. The operational overhead is real: you are now responsible for backups, upgrades, and SMTP configuration. But for a privacy-respecting blog, that trade-off is entirely worth making.
The total resource footprint on a modest VPS is under 50 MB of RAM and essentially no CPU at idle. Your readers get a fast, ad-free comment experience. You get full ownership of the conversation happening around your content. And you can export everything to a JSON file at any time, with no vendor lock-in and no third party to negotiate with.
Start with a single OAuth provider — GitHub is easiest for a technical audience — get one post with comments working end-to-end, and expand from there. The complexity ceiling for a personal blog is low, and the peace of mind from knowing exactly what data you are collecting, and from knowing it is stored only on your server, is hard to overstate.