Skip to content

Repository files navigation

cmmnts

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>

Contents

Features

  • 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.

Quick start (Docker)

Requirements: Docker Engine 24+ with the Compose plugin.

git clone https://github.com/<your-fork>/cmmnts.git
cd cmmnts
cp .env.example .env

Edit .env:

  • AUTH_SECRET — generate with openssl rand -hex 32 (or npx auth secret).
  • ADMIN_EMAILS — your email address(es), comma-separated; these accounts get admin access.
  • ROOT_DOMAIN — leave as localhost to 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 inside DATABASE_URL right 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.

Embedding the widget

<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). Same page-id on different pages = same thread.
  • theme — optional, overrides site default. Values: light, dark, or auto.

Local development

npm install
cp .env.example .env   # point DATABASE_URL at your Postgres instance
npm run db:push
npm run dev

npm 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.

Environment variables

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).

Deployment

How it works

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).

Domain configuration

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.

Reverse proxy

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.

HTTPS

nginx serves plain HTTP by default — no built-in Let's Encrypt support. To add HTTPS after getting your certificate:

  1. Mount your cert and key into the nginx service in docker-compose.yml:
    nginx:
      volumes:
        - ./certs:/etc/nginx/certs:ro
      ports:
        - "80:80"
        - "443:443"
  2. Uncomment the HTTPS block in nginx/nginx.conf.template and point ssl_certificate and ssl_certificate_key at your files.
  3. 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.

Database: bundled or external Postgres

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.):

  1. Set DATABASE_URL to your connection string.
  2. Clear COMPOSE_PROFILES= (blank).
  3. docker compose up -d --build.

The app retries database connection for ~30s on startup, so timing doesn't matter.

Updating

./scripts/update.sh
# or: git pull && docker compose up -d --build

Database 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

Backup / restore

./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.

Troubleshooting

  • app exits on startup: Check docker compose logs app. Common: AUTH_SECRET not set, bad DATABASE_URL, or database unreachable.
  • Port in use: Something else holds port 80 or a LOCAL_*_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 rebuild WIDGET_APP_URL.

Other platforms

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.

Architecture

See ARCHITECTURE.md for the full design: auth systems, event bus, SSE, moderation flow, uploads, and request diagrams.

Tech stack

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

Project structure

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

Security

  • 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 with nosniff. No data: 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.

Contributing

See AGENTS.md for coding standards and conventions. Before opening a PR, ensure:

  • npm run build succeeds
  • npm run typecheck passes
  • npm run lint passes

License

No license file yet — ask the repo owner before reusing this beyond personal/eval purposes.

About

Cmmnts is a self-hostable, open-source commenting platform with everything you need - threaded comments, moderation, OAuth authentication, and a full-featured admin console. Integrate it into any website with just one custom element.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages