Self-hosted universal comments for blogs and static websites. Public commenters are anonymous. Admins sign in with GitHub OAuth. Comments render through a small iframe widget backed by Go, SQLite, server-rendered HTML, sanitized GitHub-flavored Markdown, and a layered backend.
go run ./cmd/serverDefault local config:
BASE_URL=http://localhost:8080
PORT=8080
DATABASE_PATH=deadcomments.dbFor the bundled Docusaurus test stand, seed a demo site:
DEADCOMMENTS_DEV_SEED=1 go run ./cmd/serverThat creates a docs-demo site with automatic moderation and allowed origins for localhost:3000 and localhost:3001.
Create a GitHub OAuth app:
- Homepage URL:
http://localhost:8080 - Authorization callback URL:
http://localhost:8080/auth/github/callback
Set:
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GITHUB_ALLOWED_LOGINS=your-github-login
SERVER_SECRET="$(openssl rand -hex 32)"
SESSION_SECRET="$(openssl rand -hex 32)"
TRIPCODE_SECRET="$(openssl rand -hex 32)"Only logins listed in GITHUB_ALLOWED_LOGINS can access /admin. If GitHub OAuth client credentials are configured, GITHUB_ALLOWED_LOGINS must contain at least one login; deadcomments fails closed instead of allowing arbitrary GitHub users.
For Docker Compose, copy .env.example to .env and fill in the same values. GitHub OAuth uses the client ID and client secret; a GitHub App ID is not required by deadcomments.
| Variable | Default | Purpose |
|---|---|---|
BASE_URL |
http://localhost:8080 |
Public base URL used for OAuth callback construction |
DATABASE_PATH |
deadcomments.db |
SQLite database path |
DATABASE_MAX_OPEN_CONNS |
auto, between 4 and 16 | Optional SQLite connection pool limit |
DATABASE_MAX_IDLE_CONNS |
up to 4 | Optional SQLite idle connection pool limit |
DEADCOMMENTS_ENV |
empty | Set to production to require explicit stable secrets |
SERVER_SECRET |
generated on boot in development if empty | HMAC salt for IP, email, user-agent hashes, and embed submit tokens |
SESSION_SECRET |
SERVER_SECRET in development |
Admin session token hashing and CSRF signing |
TRIPCODE_SECRET |
SERVER_SECRET in development |
HMAC secret for public anonymous tripcodes |
GITHUB_CLIENT_ID |
empty | GitHub OAuth client ID |
GITHUB_CLIENT_SECRET |
empty | GitHub OAuth client secret |
GITHUB_ALLOWED_LOGINS |
empty | Comma-separated GitHub logins allowed to administer; required when OAuth is configured |
PORT |
8080 |
HTTP port |
SESSION_TTL_HOURS |
720 |
Admin session lifetime |
BEHIND_TRUSTED_PROXY |
false |
Trust X-Forwarded-For/X-Real-IP; enable only behind a proxy that strips untrusted forwarded headers |
METRICS_TOKEN |
empty | Optional bearer token required for GET /metrics |
DEADCOMMENTS_DEV_SEED |
empty | Set to 1 to create the local demo site |
<div id="comments"></div>
<script
src="https://comments.example.com/widget.js"
data-site="my-blog"
data-page="/posts/my-article"
data-target="#comments"
data-theme="auto"
data-sort="oldest"
data-input-position="bottom"
data-show-annotations="true"
data-locale="en">
</script>Use explicit data-page keys. data-page="auto" is supported as a convenience and resolves to location.pathname + location.search.
Comments widget attributes:
| Attribute | Required | Default | Values | Notes |
|---|---|---|---|---|
data-site |
yes | none | site key | Site key configured in the admin panel. |
data-page |
yes | none | explicit page key, or auto |
Prefer explicit stable page keys. auto resolves to location.pathname + location.search. |
data-target |
no | #comments |
CSS selector | Container where the iframe is mounted. Falls back to the script parent element. |
data-theme |
no | auto |
auto, light, dark, minimal, inherit |
inherit reads host container colors and sends safe CSS variables into the iframe. |
data-sort |
no | oldest |
oldest, newest, best |
Initial public comment order. best currently means most active approved thread first. |
data-input-position |
no | bottom |
top, bottom |
Main form placement relative to the comment list. |
data-show-annotations |
no | true |
true, false |
Set to false to keep annotation comments out of the bottom page-level thread. |
data-locale |
no | host <html lang> or browser language |
en, uk |
Unsupported locales fall back to English. |
Host applications can also control both the comments iframe and inline annotation UI explicitly:
window.deadcomments?.setTheme("dark")
window.deadcomments?.setTheme("light")
window.deadcomments?.setTheme("auto")
window.deadcomments?.setTheme("minimal")
window.deadcomments?.setTheme("inherit")This is the recommended integration point for site-level theme switches. The API updates every loaded deadcomments widget on the page, including the annotation sidebar.
Deadcomments can also run Medium-style comments bound to selected text on the host page. This uses a separate script and a separate annotations table, while the message itself is still a normal comment and goes through the same origin checks, page state checks, tripcode identity handling, Markdown rendering, moderation rules, events, and admin workflow.
<script
src="https://comments.example.com/annotations.js"
data-site="my-blog"
data-page="/posts/my-article"
data-content-selector="article"
data-theme="auto"
data-locale="en">
</script>Useful attributes:
| Attribute | Required | Default | Values | Notes |
|---|---|---|---|---|
data-site |
yes | none | site key | Site key configured in the admin panel. |
data-page |
yes | none | explicit page key, or auto |
Prefer explicit stable page keys. auto resolves to location.pathname + location.search. |
data-content-selector |
no | article, main |
CSS selector | Content area where text selection can create annotations. |
data-selector |
no | none | CSS selector | Backward-compatible alias for data-content-selector. |
data-theme |
no | auto |
auto, light, dark, minimal, inherit |
Controls annotation popovers and sidebar. Can also be changed through window.deadcomments.setTheme(...). |
data-locale |
no | host <html lang> or browser language |
en, uk |
Unsupported locales fall back to English. |
data-min-selection-length |
no | 2 |
1-200 |
Minimum selected characters. Values outside the range are clamped. |
data-max-selection-length |
no | 2000 |
100-5000 |
Maximum selected characters stored as a quote. Values outside the range are clamped. |
Annotations store the selected quote, a root selector, surrounding text context, optional text offsets, and a stable text hash. If the article HTML changes later, the script first tries the stored offsets and then falls back to finding the selected quote inside the configured root. If the quote no longer exists, the annotation remains in storage and the API but is not highlighted on that page render.
In one terminal:
DEADCOMMENTS_DEV_SEED=1 go run ./cmd/serverIn another:
cd examples/docusaurus
npm install
npm startOpen http://localhost:3000/docs/intro, threading, moderation, and annotations. The example loader injects http://localhost:8080/widget.js using site key docs-demo; the annotations page also injects http://localhost:8080/annotations.js.
To run the comments service and the Docusaurus test stand together:
cp .env.example .env
# Replace SERVER_SECRET, SESSION_SECRET, and TRIPCODE_SECRET with stable random values.
# For local-only testing, `openssl rand -hex 32` is enough for each one.
docker compose up --buildOpen:
- comments service:
http://localhost:8080 - Docusaurus test stand:
http://localhost:3000/docs/intro
The compose setup enables DEADCOMMENTS_DEV_SEED=1, so the docs-demo site is created automatically with allowed origins for local Docusaurus testing. Compose intentionally requires SERVER_SECRET, SESSION_SECRET, and TRIPCODE_SECRET from your environment or .env file instead of shipping usable secrets in docker-compose.yml.
SQLite data is stored in the named Docker volume deadcomments-data. To reset local test data:
docker compose down -vFor admin GitHub OAuth inside Docker, export these before starting compose:
export GITHUB_CLIENT_ID=...
export GITHUB_CLIENT_SECRET=...
export GITHUB_ALLOWED_LOGINS=your-github-login
docker compose up --buildOr create a local .env from .env.example; Docker Compose will load it automatically. Never commit real OAuth secrets.
Open http://localhost:8080/admin. From the admin panel you can:
- create and edit sites
- configure allowed origins and moderation mode
- inspect pages and change page state
- approve, reject, spam, delete, and edit comments
- manage reserved tripcode identities
- ban IP hashes and add word-ban rules
Tripcodes provide stable public identity hints without public user accounts. This is identity, not authentication.
Public commenters can write their name as:
Display Name##secret
The server parses this into:
- display name:
Display Name - tripcode secret:
secret
The submitted secret is never stored on the comment and is never returned to the frontend.
If no reserved identity exists for the normalized display name, a submitted secret generates a stable public tripcode:
base32(HMAC_SHA256(TRIPCODE_SECRET, normalized_secret))[0:10]
The public UI shows:
Display Name ◆K7F3Q9M2PA
This means only that the same anonymous actor used the same secret. It does not prove the real-world identity of Display Name.
Admins can create reserved identities at /admin/identities.
Reserved identity fields include:
- global or site-specific scope
- display name
- secret hash
- public tripcode
- badge type:
verified,admin,author,custom - optional badge label
If a comment uses a reserved name:
- no secret: rejected
- wrong secret: rejected
- correct secret: comment is attached to the reserved identity and rendered with the configured badge
This blocks simple spoofing of reserved names. For example, if UT3USW is reserved, UT3USW and UT3USW##wrong are rejected, while UT3USW##correct-secret is accepted.
If no site exists, the dashboard prompts you to create the first site.
SQLite migrations live in migrations/. The app configures SQLite per connection with foreign keys, WAL mode, synchronous=NORMAL, a busy timeout, in-memory temporary storage, a larger page cache, memory-mapped reads, and immediate write transactions. The default connection pool allows concurrent WAL reads; tune DATABASE_MAX_OPEN_CONNS and DATABASE_MAX_IDLE_CONNS if the host has strict memory limits.
The service never stores raw IP addresses, raw user-agent strings, or raw commenter email addresses. IP, email, and user-agent values are HMAC-SHA256 hashes using SERVER_SECRET. If a commenter provides an email, deadcomments also stores a Gravatar-compatible MD5 avatar hash so the public widget can show an avatar without storing the email itself. Markdown is rendered with goldmark GFM and sanitized with bluemonday. Admin POST routes use CSRF tokens. Admin sessions use HttpOnly SameSite cookies.
Public comments can be arbitrarily deep in storage. The iframe renders root comments normally, visually flattens deeper replies under the root, and adds a replying to @author label instead of indenting forever.
The public iframe UI is localized through a small server-side catalog shared with embed JavaScript and public API response messages. Keep public copy in internal/i18n/embed.go instead of hardcoding strings in templates or handlers.
Important domain actions publish durable events to the events table and record handler delivery state in event_deliveries. The current bus runs synchronous in-process handlers; future email, Discord, Telegram, webhook, or async worker handlers can subscribe without changing HTTP handlers.
Current event sources include site create/update, page auto-create and state changes, comment create/edit/status changes, IP bans, word bans, and admin login. The admin event log is available at /admin/events.
Identity create/update/delete and identity secret reset also publish events.
Comment moderation history is now an event subscriber: the audit handler listens to comment events and writes moderation_events. That keeps audit behavior out of handlers and away from core comment mutation code.
Kubernetes-friendly status endpoints:
GET /livez: process liveness, no database dependencyGET /readyz: readiness check with SQLite ping, returns503if the database is unavailableGET /healthz: readiness-compatible alias for simple load balancersGET /status: JSON status payload with checks and server time
Prometheus metrics are exposed at:
GET /metrics
If METRICS_TOKEN is set, Prometheus must send:
Authorization: Bearer <METRICS_TOKEN>
The backend exports Go runtime/process metrics plus application metrics:
deadcomments_http_requests_totaldeadcomments_http_request_duration_secondsdeadcomments_http_response_size_bytesdeadcomments_http_requests_in_flightdeadcomments_readiness_checks_totaldeadcomments_domain_events_totaldeadcomments_comments_created_totaldeadcomments_comment_moderation_actions_totaldeadcomments_page_events_totaldeadcomments_site_events_totaldeadcomments_ban_events_totaldeadcomments_identity_events_totaldeadcomments_admin_logins_total
Business metrics are recorded by an event-bus subscriber, not directly from HTTP handlers or core mutation logic. Keep metric labels low-cardinality: status, action, route pattern, result, and bounded enum-like values are OK; site keys, page keys, comment IDs, IP hashes, user names, and free-form text are not OK as labels.
Example Kubernetes probes:
livenessProbe:
httpGet:
path: /livez
port: http
readinessProbe:
httpGet:
path: /readyz
port: http- Run behind HTTPS and set
BASE_URLto the HTTPS origin. - Set stable, high-entropy
SERVER_SECRET,SESSION_SECRET, andTRIPCODE_SECRETbefore accepting comments. HTTPSBASE_URLorDEADCOMMENTS_ENV=productionrequires these secrets explicitly. - Keep
GITHUB_ALLOWED_LOGINSexplicit. - Configure each site with exact allowed origins. Public write requests require a valid
OriginorReferer; an empty allowed-origin list means any valid origin is accepted, not that missing origin metadata is accepted. - Put the SQLite database on durable storage.
- Add process supervision with systemd, Docker, Nomad, Fly, or another single-service runtime.
- Terminate TLS at a reverse proxy and forward standard headers. Set
BEHIND_TRUSTED_PROXY=trueonly when that proxy strips incomingX-Forwarded-ForandX-Real-IPfrom untrusted clients. - Expose
/metricsonly to Prometheus or a trusted internal network. - Monitor SQLite file size, pending moderation volume, readiness failures, comment moderation outcomes, and HTTP 4xx/5xx rates.
SQLite backups can be taken online:
sqlite3 deadcomments.db ".backup 'deadcomments-$(date +%Y%m%d-%H%M%S).db'"Back up both the database and the exact SERVER_SECRET; without the secret, future hashes cannot be matched against existing bans or anonymous identities.
go test ./...The suite covers Markdown sanitization, origin validation, page auto-creation, comment and reply creation, moderation decisions, tree flattening, and banned-IP rejection.
GitHub Actions workflows live in .github/workflows/.
CI: Go formatting, module consistency, vet, race tests, coverage artifact,govulncheck, Docusaurus build, and Docker Compose smoke test.CodeQL: scheduled and PR security analysis for Go and JavaScript.Release: triggered by tags matchingv*.*.*.
To publish a release:
git tag v0.1.0
git push origin v0.1.0The release workflow:
- runs the test gate
- builds Linux, macOS, and Windows release archives
- includes the binary plus migrations/templates/static/widget runtime assets
- creates SHA256 checksums
- publishes a GitHub Release
- publishes a multi-arch Docker image to GHCR
Dependabot is configured for GitHub Actions, Go modules, Docker base images, and the Docusaurus test stand.