cmmnts is a self-hostable commenting platform, if you want threaded, moderated, realtime
comments on your site without handing visitor data to a third party. Drop in one custom element
(<cmmnts-widget>) and you're done. Comes with the widget, an admin dashboard, and
the REST API behind both.
<script src="https://widget.cmmnts.example.com/embed.js"></script>
<cmmnts-widget site-key="YOUR_SITE_KEY" page-id="/blog/my-post"></cmmnts-widget>- Features
- Quick start (Docker)
- Embedding the widget
- Local development
- Environment variables
- Deployment
- Architecture
- Tech stack
- Project structure
- Security
- Contributing
- License
- Threaded comments — one level of replies, pinning, sort by recent or oldest.
- Moderation — pending/approved/rejected workflow (optional per site), hide/unhide, soft delete, spam reporting with auto-hide.
- Markdown-lite composer — bold/italic/strikethrough/inline code/code blocks, @mentions, emoji picker.
- Images & GIFs — paste, drag-drop, or a URL. Optional GIPHY picker, proxied server-side so your key never reaches the browser. Live thumbnail while composing.
- Sign-in — anonymous (per-site configurable) or OAuth (Google, GitHub, Microsoft Entra ID, Facebook, Yandex, Patreon, or any custom OAuth2 provider). Hand-rolled PKCE flow, no cookies, built for embedding on someone else's domain.
- Realtime — Server-Sent Events tell clients something changed, then they pull the real data from REST. Keeps the server dumb and avoids stale-vs-live edge cases.
- Notifications — email a comment's author when someone replies; alert site owners on every new comment via webhook, Slack, Telegram, or email (all optional, per-site).
- Moderator tools — block a comment's author from the site entirely, not just remove the one comment.
- RSS — a feed of recent comments per-site and per-page, for anyone who wants to follow discussion without the widget.
- Multi-site — one deployment can serve many sites, each with its own allowed domains, theme, and moderation settings.
- Self-hostable — one Docker image, health checks, graceful shutdown, bundled or external Postgres, nginx routing already wired up.
Requirements: Docker Engine 24+ with the Compose plugin.
git clone https://github.com/<your-fork>/cmmnts.git
cd cmmnts
cp .env.example .envEdit .env:
AUTH_SECRET— generate withopenssl rand -hex 32(ornpx auth secret).ADMIN_EMAILS— your email address(es), comma-separated; these accounts get admin access.ROOT_DOMAIN— leave aslocalhostto try it out locally, or set your real domain (see Domain configuration).POSTGRES_PASSWORD— if using the bundled database (the default), set this and update the matching password insideDATABASE_URLright below it.
Then:
./scripts/start.sh
# equivalent to: docker compose up -d --build- Local (
ROOT_DOMAIN=localhost):http://localhost:3000(admin console),http://localhost:3001(API),http://localhost:4000/embed.js(widget bundle). - Real domain:
https://console.cmmnts.<your-domain>,https://api.cmmnts.<your-domain>,https://widget.cmmnts.<your-domain>.
Sign in to the console with an account matching ADMIN_EMAILS (configure at least one OAuth
provider — see Environment variables), create a site, and embed it.
<script src="https://widget.cmmnts.<your-domain>/embed.js"></script>
<cmmnts-widget site-key="YOUR_SITE_KEY" page-id="/blog/my-post"></cmmnts-widget>site-key— from the admin console.page-id— any stable string for this page (e.g. slug or URL path). Samepage-idon different pages = same thread.theme— optional, overrides site default. Values:light,dark, orauto.
npm install
cp .env.example .env # point DATABASE_URL at your Postgres instance
npm run db:push
npm run devnpm run dev runs three watchers in parallel:
| App | Dev URL |
|---|---|
| API | http://localhost:3001 |
| Admin | http://localhost:5173 |
| Widget | http://localhost:5174 |
Other scripts: npm run build, npm run typecheck, npm run lint, npm run db:migrate, npm run db:seed.
See ARCHITECTURE.md for the system design and AGENTS.md for coding standards.
See .env.example for detailed docs on each variable. Copy it to .env and edit.
Domain routing (nginx subdomain routing + local no-DNS ports)
| Variable | Default | Purpose |
|---|---|---|
ROOT_DOMAIN |
localhost |
Your domain. widget./console./api. subdomains are built from this. |
BASE_SUBDOMAIN |
cmmnts |
Shared prefix, e.g. console.cmmnts.example.com. |
WIDGET_SUBDOMAIN / CONSOLE_SUBDOMAIN / API_SUBDOMAIN |
widget / console / api |
Per-app subdomain. |
LOCAL_CONSOLE_PORT / LOCAL_API_PORT / LOCAL_WIDGET_PORT |
3000 / 3001 / 4000 |
Plain-HTTP local ports, no DNS required. |
App runtime
| Variable | Default | Purpose |
|---|---|---|
PORT |
3001 |
Port the Node server listens on inside its container. |
Database
| Variable | Default | Purpose |
|---|---|---|
COMPOSE_PROFILES |
bundled-db |
Set blank to skip starting the bundled Postgres container. |
POSTGRES_PASSWORD |
(placeholder — change it) | Password for the bundled Postgres container. |
DATABASE_URL |
points at the bundled container | Full Postgres connection string — point this at your own instance for external mode. |
Auth
| Variable | Required | Purpose |
|---|---|---|
AUTH_SECRET |
yes | Signs admin session cookies and widget visitor identity tokens. |
ADMIN_EMAILS |
yes | Comma-separated emails granted admin access. |
AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET |
optional | Enables Google sign-in. |
AUTH_GITHUB_ID / AUTH_GITHUB_SECRET |
optional | Enables GitHub sign-in. |
AUTH_MICROSOFT_ENTRA_ID_ID / _SECRET / _TENANT_ID |
optional | Enables Microsoft sign-in. |
AUTH_FACEBOOK_ID / AUTH_FACEBOOK_SECRET |
optional | Enables Facebook sign-in for widget visitors. |
AUTH_YANDEX_ID / AUTH_YANDEX_SECRET |
optional | Enables Yandex sign-in for widget visitors. |
AUTH_PATREON_ID / AUTH_PATREON_SECRET |
optional | Enables Patreon sign-in for widget visitors. |
AUTH_CUSTOM_ID / _SECRET / _AUTHORIZE_URL / _TOKEN_URL / _USERINFO_URL / _SCOPE |
optional | Any other OAuth2 provider for widget visitors — no code change needed. |
Email (optional)
| Variable | Default | Purpose |
|---|---|---|
SMTP_USER / SMTP_PASS |
unset | Enables email: reply notifications and, per-site, admin alerts on new comments. Defaults to Gmail's SMTP relay — an app password is enough. |
SMTP_FROM |
SMTP_USER |
From address on outgoing mail. |
SMTP_HOST / SMTP_PORT |
smtp.gmail.com / 465 |
Override for a non-Gmail SMTP provider. |
Widget build
| Variable | Default | Purpose |
|---|---|---|
WIDGET_APP_URL |
http://localhost:3001 |
Public API origin baked into the widget bundle at build time — must match API_SUBDOMAIN.BASE_SUBDOMAIN.ROOT_DOMAIN in production. |
Uploads / GIFs
| Variable | Default | Purpose |
|---|---|---|
UPLOADS_DIR |
/app/uploads (Docker) |
Where pasted/uploaded comment images are stored. |
UPLOAD_ORPHAN_TTL_MIN |
30 |
Minutes an uploaded-but-never-posted image is kept before cleanup. |
GIPHY_API_KEY |
unset | Enables the composer's GIF picker (stays server-side). |
One Docker image runs everything: Express serves the REST API, the built admin dashboard, and the widget bundle — all from one process, one port.
Internet → nginx (reverse proxy, routes by subdomain) → app (Express) → postgres
console.* and api.* both hit the same process, so the admin dashboard's same-origin fetch('/api/...') calls work with no CORS hacks. widget.* serves just the static embed.js file; the widget's actual API calls go to api.* via WIDGET_APP_URL (baked in at build time).
Hostnames are built from ROOT_DOMAIN, BASE_SUBDOMAIN, and per-app subdomains. With defaults and ROOT_DOMAIN=example.com:
https://widget.cmmnts.example.com → embeddable widget bundle
https://console.cmmnts.example.com → admin dashboard
https://api.cmmnts.example.com → API
Change the *_SUBDOMAIN vars in .env to customize. Point DNS A records at your server for all three hostnames. Update WIDGET_APP_URL to match your API hostname.
Don't have a domain yet? Use ROOT_DOMAIN=localhost — the LOCAL_*_PORT env vars give you plain-HTTP local endpoints without DNS.
nginx routes by subdomain, compresses responses, limits upload size to 10MB (covers the 5MB image limit), and disables buffering for SSE (so notifications stream live, not delayed). It also forwards X-Forwarded-Proto, X-Forwarded-For, and Host, which the app needs for correct origins and session cookies.
nginx serves plain HTTP by default — no built-in Let's Encrypt support. To add HTTPS after getting your certificate:
- Mount your cert and key into the
nginxservice indocker-compose.yml:nginx: volumes: - ./certs:/etc/nginx/certs:ro ports: - "80:80" - "443:443"
- Uncomment the HTTPS block in
nginx/nginx.conf.templateand pointssl_certificateandssl_certificate_keyat your files. - Restart nginx:
docker compose up -d --build nginx.
Or use Cloudflare, a cloud load balancer, or similar to terminate TLS upstream — keep nginx on plain HTTP behind it.
Bundled (default): Set POSTGRES_PASSWORD in .env and match it in DATABASE_URL. The COMPOSE_PROFILES=bundled-db setting starts the Postgres service; data lives in the postgres_data named volume.
External (Neon, RDS, Supabase, etc.):
- Set
DATABASE_URLto your connection string. - Clear
COMPOSE_PROFILES=(blank). docker compose up -d --build.
The app retries database connection for ~30s on startup, so timing doesn't matter.
./scripts/update.sh
# or: git pull && docker compose up -d --buildDatabase schema changes apply automatically on startup. If a schema change is destructive (would drop data), the container exits with an error. Review the error, then run:
docker compose run --rm app npx prisma db push --accept-data-loss./scripts/backup.sh # writes to backups/<timestamp>/
./scripts/restore.sh backups/<timestamp>backup.sh dumps the database and archives uploads. For external databases, use your provider's backup tools.
appexits on startup: Checkdocker compose logs app. Common:AUTH_SECRETnot set, badDATABASE_URL, or database unreachable.- Port in use: Something else holds port
80or aLOCAL_*_PORT. Stop it or change.env. - Widget shows nothing / CORS error: Check the site's "Allowed Domains" in the admin console.
- Testing HTTPS site with
http://widget origin: Browser blocks mixed content. Use localhost to test, or tunnel your dev server with HTTPS (ngrok, Cloudflare Tunnel) and rebuildWIDGET_APP_URL.
Coolify / Dokploy: Deploy the docker-compose.yml directly. Set the same .env variables in their UI before deploying.
DigitalOcean / any VM: Provision a VM with Docker, then follow Quick start.
Render: Use render.yaml. Render terminates TLS and gives one URL — the widget bundle, admin, and API all share it. WIDGET_APP_URL auto-reaches the bundle if set before the first build.
Fly.io: Use fly.toml. Pass build args explicitly: fly deploy --build-arg WIDGET_APP_URL=https://<app-name>.fly.dev.
Railway: Use railway.toml. Add their Postgres plugin or set DATABASE_URL.
See ARCHITECTURE.md for the full design: auth systems, event bus, SSE, moderation flow, uploads, and request diagrams.
| Layer | Technology |
|---|---|
| Admin dashboard | React, Vite, React Router, Tailwind CSS, Radix UI |
| Widget | Vanilla TypeScript, esbuild, Shadow DOM (no framework, no iframe) |
| API | Express, TypeScript |
| Database | PostgreSQL via Prisma ORM |
| Admin auth | Auth.js (session cookies) |
| Visitor auth | Hand-rolled PKCE OAuth2 + HMAC-signed token (no cookies) |
| Realtime | Server-Sent Events + an in-process domain event bus |
| Deployment | Docker (multi-stage build), nginx, Docker Compose |
apps/
admin/ React admin dashboard (Vite SPA)
api/ Express backend (also serves built admin/widget bundles)
src/
controllers/
services/ Business logic, transactions
routes/
middleware/ Auth, validation, rate limiting
events/ Domain event bus, SSE engine
lib/ Sanitization, markdown, uploads, OAuth, tokens
prisma/ schema.prisma
widget/ Vanilla TS embeddable widget (Shadow DOM custom element)
src/
index.ts <cmmnts-widget> element
render.ts DOM building
composer.ts Markdown editor + image preview
api.ts REST client
auth.ts PKCE OAuth flow
nginx/ Reverse proxy config
scripts/ Helper scripts (start, stop, update, backup, restore)
docker-compose.yml
Dockerfile
ARCHITECTURE.md
AGENTS.md
- Comment HTML is sanitized with a strict allowlist (
p,br,strong,em,s,pre,code,img, mentions). Everything else is stripped. - Widget requests are origin-validated against the site's allowed domains.
- Rate limiting is per-fingerprint and per-IP — can't bypass one by rotating the other.
- Uploaded images are validated by magic bytes (not
Content-Type), stored with random names, served withnosniff. Nodata:URLs or SVG. - Comment ownership for signed-in users is verified by bearer token, not fingerprint.
Found a security issue? Report it privately, not as a public issue.
See AGENTS.md for coding standards and conventions. Before opening a PR, ensure:
npm run buildsucceedsnpm run typecheckpassesnpm run lintpasses
No license file yet — ask the repo owner before reusing this beyond personal/eval purposes.