- Features
- Bundle size
- Quick Start
- Embedding
- Customisation
- Configuration
- Email Notifications
- Deployment
- Upgrading
- Roadmap
- Contributing
- License
| Feature | Description |
|---|---|
| Lightweight embed | A <link> and a <script>. No npm, no bundler, nothing to install on the host page. client.js is 5.8 KB gzip; client.css is 12.7 KB gzip. Total: ~18.5 KB. |
| SQLite, no database server | Everything lives in a single file on your machine. Nothing to provision, nothing to pay for, trivial to back up. |
| Markdown | Bold, italic, code blocks, lists, and blockquotes. Rendered server-side and sanitised. Commenters get a live preview as they type. |
| Nested replies | Threaded conversations up to three levels deep, with collapsible thread lines. |
| Spam protection | Honeypot field catches bots silently. Blocked word list queues matching comments for manual review. Both are configurable per domain. |
| Email notifications | Get notified when a new comment arrives or when someone replies. Visitors get notified when someone replies to them too. SMTP credentials are stored encrypted (AES-256-GCM). |
| Admin dashboard | Approve, reject, pin, edit, and delete comments. Search across all threads. Everything is at /admin. |
| Full customisation | Every colour, border, and surface is a CSS variable. Swap the widget's icons with your own SVGs, or hide them. Override the form title. Skip client.css entirely and write your own styles from scratch. |
| Gravatar with initials fallback | Shows a commenter's Gravatar if they have one. Falls back to a clean initial avatar if they don't. |
| Stable thread keys | Widget reads <link rel="canonical"> automatically, so threads survive URL changes on Ghost, Hugo, Jekyll, and WordPress with no config. Override manually with data-url when needed. |
| Comment editing | Authors can edit their own comments within 15 minutes of posting. Edits are inline with no page reload required. The edit token is stateless: the server derives it from an HMAC-SHA256 of the comment ID and timestamp, so nothing extra is stored in the database. The token lives in memory only, so reloading the page clears it. |
| MIT licensed | Free to use, self-host, and modify. |
| File | Raw | Gzip |
|---|---|---|
client.js |
18.05 KB | 5.83 KB |
client.css |
92.36 KB | 12.65 KB |
| Total | 110.41 KB | 18.49 KB |
Run npm run bundle-size locally to check current sizes after a build.
git clone https://github.com/KarthikeyanKC/discuss.git
cd discuss
npm install --production
cp .env.example .env # set JWT_SECRET at minimum
npm run setup # create your admin account
npm start # server starts on port 3000Visit /admin to log in, register your first domain, and grab your embed snippet.
For local development use npm run dev instead. It restarts on file changes.
When you're ready to run this in production, head to the Deployment section. It covers running the server as a systemd service with automatic restarts, and configuring Nginx or Apache as a reverse proxy with SSL.
There are three ways to embed the widget, depending on how much control you want. Most people start with the basic embed and never need anything else.
Go to Admin > Domains > Settings > Embed and copy the snippet for your domain. Paste it into any page where you want comments to appear:
<link rel="stylesheet" href="https://discuss.example.com/client.css">
<div id="discuss-comments"></div>
<script src="https://discuss.example.com/client.js"></script>The script figures out the server URL from its own src, then pulls the domain's colour and settings automatically. Every page gets its own comment thread based on window.location.pathname with nothing else to configure.
If you want full control over the look, skip the <link> tag entirely:
<div id="discuss-comments"></div>
<script src="https://discuss.example.com/client.js"></script>The widget loads with no styles at all. Every element has a named class, so you have full control. See Customisation for the list of CSS custom properties and an example stylesheet.
By default the widget uses window.location.pathname as the thread's identifier. If you ever rename a URL, the comments under the old path become unreachable from the widget (they're still in the database, just disconnected).
The widget checks <link rel="canonical"> automatically, so if your site already outputs canonical tags (Ghost, Hugo, Jekyll, and WordPress all do), threads survive slug changes with no extra config.
If you need to set the key manually, use the data-url attribute:
<div id="discuss-comments" data-url="/posts/my-stable-slug"></div>Pick a value that will never change: a post ID, a UUID, or a slug you're permanently committed to. data-url takes priority over both the canonical tag and window.location.pathname.
The snippet from your admin dashboard handles everything automatically for most sites. Use new DiscussWidget({...}) when you need to override the thread key, the brand colour, or the form's appearance:
<link rel="stylesheet" href="https://discuss.example.com/client.css">
<div id="discuss-comments"></div>
<script src="https://discuss.example.com/client.js"></script>
<script>
new DiscussWidget({
serverUrl: 'https://discuss.example.com',
postUrl: '/posts/my-post',
primaryColor: '#2563eb',
});
</script>| Option | Default | Description |
|---|---|---|
serverUrl |
inferred from script src |
URL of your Discuss server |
postUrl |
window.location.pathname |
Thread key for this page |
primaryColor |
fetched from server | Brand colour for buttons and links |
domainId |
(none) | Required when posting from a cross-origin admin context |
darkSelector |
(none) | CSS selector that activates dark mode. When this selector matches an ancestor of the widget, the dark colour tokens are applied. Example: '[data-theme="dark"]' or '.dark' |
title |
'Leave a comment' |
Heading above the comment form |
placeholder |
'Share your thoughts... (*markdown* supported)' |
Placeholder text for the comment textarea |
icons.name |
built-in person icon | Icon inside the name input. Pass any SVG string, or '' to hide it |
icons.email |
built-in envelope icon | Icon inside the email input. Pass any SVG string, or '' to hide it |
icons.submit |
built-in send icon | Icon inside the submit button. Pass any SVG string, or '' to hide it |
See Colours, title, and icons for examples of customising the form's appearance.
If your Discuss server is on a different domain from your site, register your site's domain in the admin dashboard under Domains. CORS is configured automatically per registered domain.
To test comments on a local dev server, add it as an allowed origin:
- Go to Admin > Domains > Settings > General
- Under Additional allowed origins, add your local dev server:
http://localhost:4321 - Save. The widget on your local server can now fetch and post comments against your production domain record.
Origins are scoped to the domain they're added to, so an origin you add to myblog.com can't reach a different domain on the same Discuss instance.
Authors can edit their own comments within 15 minutes of posting. An edit button appears next to the timestamp immediately after a comment is posted. Clicking it opens an inline edit form in place, reusing the same form styles as the create form.
How the token works
When a comment is created, the server returns a one-time edit token alongside the comment ID. The token is an HMAC-SHA256 of "${id}:${created_at}" signed with the server's JWT_SECRET. The server never stores it; it re-derives the expected token on each PATCH request and compares with crypto.timingSafeEqual. A token for comment 42 cannot authenticate a PATCH to comment 43 because the HMAC binds both the ID and the creation timestamp.
The token is held in memory on the widget instance. It is not written to localStorage or any cookie, so it is gone after a page reload. This is intentional: the 15-minute window is meant for quick typo fixes right after posting, not for indefinite revision.
The edit window is enforced server-side. If more than 15 minutes have elapsed since created_at, the PATCH returns 403 regardless of whether the token is valid.
The default styles are just a starting point. You can override individual tokens, restyle specific components, or skip client.css entirely and write your own from scratch.
All colours, borders, and surfaces are CSS custom properties set on #discuss-comments. Override any of them from your own stylesheet and the whole widget picks up the change without you having to touch individual selectors.
Text colours
| Token | Default (light) | Role |
|---|---|---|
--text-primary |
oklch(18.5% 0.008 250) |
Primary text |
--text-secondary |
oklch(37.5% 0.016 250) |
Body text |
--text-tertiary |
oklch(48.0% 0.020 250) |
Secondary / muted text |
--text-muted |
oklch(58.5% 0.022 250) |
Placeholder, icon colour |
--text-subtle |
oklch(71.0% 0.020 250) |
Disabled / placeholder |
Surface colours
| Token | Default (light) | Role |
|---|---|---|
--surface-base |
#ffffff |
Primary surface (cards, inputs) |
--surface-inset |
oklch(98.2% 0.004 250) |
Secondary surface (form footer) |
--surface-overlay |
oklch(95.8% 0.008 250) |
Tertiary surface (hover states) |
Border colours
| Token | Default (light) | Role |
|---|---|---|
--border-default |
oklch(84.5% 0.016 250) |
Default border |
--border-subtle |
oklch(91.2% 0.012 250) |
Subtle border (thread lines, dividers) |
--border-control |
same as --border-default |
Input / form element borders |
--border-button |
same as --border-subtle |
Button borders |
--border-strong |
oklch(71.0% 0.020 250) |
Elevated border (kbd shadow) |
Accent colours
| Token | Role |
|---|---|
--accent-fg |
Links, reply tags |
--accent-surface |
Reply tag fill |
--focus-ring |
Focus outline |
--on-primary |
Text on primary buttons |
Primary scale
When you pass primaryColor to DiscussWidget, the widget generates a 10-stop scale and sets --brand-50 through --brand-900 on the container. You can also set the scale yourself:
#discuss-comments {
--brand-600: #7c3aed;
--brand-700: #6d28d9;
--on-primary: #fff;
}Dark mode
The widget has no dark mode by default. If you use the script tag without a manual new DiscussWidget() call, set the selector as a data attribute on the container:
<div id="discuss-comments" data-dark-selector=".dark"></div>If you initialise manually, pass it as an option:
new DiscussWidget({ darkSelector: '.dark' });
// or
new DiscussWidget({ darkSelector: '[data-theme="dark"]' });When that selector matches an ancestor of the widget, the full set of dark colour tokens kicks in automatically. The widget injects a scoped <style> tag at runtime, so it works with any selector your site uses: class, attribute, or otherwise.
To override individual dark mode tokens on top of the defaults, target the same selector yourself in your stylesheet:
[data-theme="dark"] #discuss-comments {
--surface-base: #0d1117;
}Brand colour
The brand colour controls buttons, links, and focus rings. The easiest way to set it is through the admin dashboard under Domains > Settings > Appearance. It applies to all visitors automatically. If you want to override it per-page, pass primaryColor to new DiscussWidget:
<script>
new DiscussWidget({ primaryColor: '#7c3aed' });
</script>Form title
The heading above the comment form defaults to "Leave a comment". Change it with the title option:
<script>
new DiscussWidget({ title: 'Join the discussion' });
</script>Placeholder
The textarea placeholder defaults to Share your thoughts... (*markdown* supported). Override it with the placeholder option:
<script>
new DiscussWidget({ placeholder: 'Leave a comment…' });
</script>Icons
The name, email, and submit button each have a built-in SVG icon. Replace any of them with your own SVG string, or pass an empty string to hide the icon entirely:
<script>
new DiscussWidget({
icons: {
name: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="5"/><path d="M3 21a9 9 0 0 1 18 0"/></svg>',
email: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>',
submit: '', // hide the icon on the submit button
}
});
</script>The SVG is injected directly into the DOM. Size it with width and height attributes on the SVG element, or target .discuss-form-input-wrapper svg in your stylesheet. When an icon is hidden, the input adjusts its padding automatically.
Custom font
The widget uses the system font stack by default. Point it at any font your page has already loaded:
#discuss-comments {
--discuss-font-family: 'Inter', sans-serif;
}#discuss-comments
├── .discuss-comment-row
│ ├── .discuss-avatar
│ └── .discuss-comment-content
│ ├── .discuss-comment-body
│ ├── .discuss-action-btn (Reply / Share)
│ ├── .discuss-reply-form (hidden by default)
│ └── .discuss-nested
│ └── .discuss-comment-row (recurses)
│
└── .discuss-form-container
├── .discuss-form-textarea
└── .discuss-form-bottom
├── .discuss-form-inputs
│ ├── .discuss-form-input-wrapper
│ │ └── .discuss-form-input (name)
│ └── .discuss-form-input-wrapper
│ └── .discuss-form-input (email)
└── .discuss-form-actions
└── .discuss-btn .discuss-btn-primary
| Class | Notes |
|---|---|
.discuss-comment-row |
Wraps avatar and comment content |
.discuss-avatar |
Circular avatar, Gravatar if available or initials otherwise |
.discuss-comment-body |
Rendered markdown prose |
.discuss-badge |
Author / Moderator / Pinned label |
.discuss-badge-info |
Blue badge variant (Pinned) |
.discuss-badge-success |
Green badge variant (Author) |
.discuss-badge-warning |
Amber badge variant (Moderator) |
.discuss-reply-tag |
Inline @-mention link to parent comment |
.discuss-action-btn |
Reply / Share buttons below a comment |
.discuss-thread-line |
Vertical clickable thread collapse line |
.discuss-form-container |
Outer wrapper for the comment form |
.discuss-form-textarea |
Main comment textarea |
.discuss-form-input-wrapper |
Wraps icon + input field |
.discuss-input-no-icon |
Added to .discuss-form-input-wrapper when the icon slot is empty; resets left padding |
.discuss-form-input |
Name / email text inputs |
.discuss-form-actions |
Wraps the submit button |
.discuss-btn-primary |
Submit button |
.discuss-hidden |
Utility: display: none !important. Used by JS to show and hide elements |
If you embed without client.css, here is a minimal starting point. Copy it, extend it, or replace it entirely.
Important
Don't remove or rename utility classes like .discuss-hidden. The widget's JavaScript uses them to show and hide elements at runtime.
Show example stylesheet
#discuss-comments {
font-family: system-ui, sans-serif;
font-size: 1rem;
line-height: 1.6;
color: #1a1a1a;
--text-primary: #1a1a1a;
--text-secondary: #333;
--text-tertiary: #555;
--text-muted: #777;
--text-subtle: #999;
--surface-base: #fff;
--surface-inset: #f5f5f5;
--surface-overlay: #ececec;
--border-default: #ccc;
--border-subtle: #e0e0e0;
--border-control: #ccc;
--border-button: #ddd;
--border-strong: #aaa;
--accent-fg: #0055cc;
--accent-surface: #e8f0fe;
--focus-ring: #0055cc;
--on-primary: #fff;
--brand-50: #e8f0fe;
--brand-400: #4d88ff;
--brand-600: #0055cc;
--brand-700: #0047b3;
--brand-800: #003a99;
}
.discuss-comment-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.discuss-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.discuss-avatar img { width: 100%; height: 100%; object-fit: cover; }
.discuss-comment-content { flex: 1; min-width: 0; }
.discuss-comment-body {
font-size: 0.9375rem;
line-height: 1.65;
color: var(--text-primary);
margin: 0.25rem 0 0.5rem;
}
.discuss-comment-body p { margin: 0 0 0.5em; }
.discuss-comment-body p:last-child { margin-bottom: 0; }
.discuss-comment-body code {
font-family: monospace;
background: var(--surface-overlay);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.discuss-comment-body pre {
background: var(--text-primary);
color: var(--surface-base);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
}
.discuss-thread-line { display: none; }
.discuss-nested { margin-left: 2.75rem; margin-top: 1rem; }
.discuss-action-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
font-size: 0.8125rem;
color: var(--text-muted);
border: 1px solid var(--border-button);
border-radius: 4px;
background: transparent;
cursor: pointer;
text-decoration: none;
}
.discuss-action-btn:hover { color: var(--text-primary); background: var(--surface-overlay); }
.discuss-badge {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 999px;
}
.discuss-badge-info { background: #dbeafe; color: #1e40af; }
.discuss-badge-warning { background: #fef3c7; color: #b45309; }
.discuss-reply-tag {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
background: var(--accent-surface);
color: var(--accent-fg);
text-decoration: none;
margin-right: 0.25rem;
}
.discuss-form-container {
border: 1px solid var(--border-control);
border-radius: 6px;
overflow: hidden;
margin-top: 2rem;
}
.discuss-form-container:focus-within { border-color: var(--focus-ring); }
.discuss-form-textarea {
display: block;
width: 100%;
min-height: 80px;
padding: 0.75rem;
border: none;
resize: vertical;
font: inherit;
font-size: 0.9375rem;
outline: none;
background: var(--surface-base);
color: var(--text-primary);
}
.discuss-form-textarea::placeholder { color: var(--text-subtle); }
.discuss-form-bottom {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem;
background: var(--surface-inset);
border-top: 1px solid var(--border-subtle);
}
.discuss-form-input-wrapper {
display: flex;
align-items: center;
position: relative;
flex: 1;
min-width: 120px;
}
.discuss-form-input-wrapper svg {
position: absolute;
left: 0.6rem;
width: 1rem;
height: 1rem;
color: var(--text-muted);
pointer-events: none;
}
.discuss-form-input {
width: 100%;
padding: 0.5rem 0.5rem 0.5rem 2rem;
border: 1px solid var(--border-control);
border-radius: 4px;
font: inherit;
background: var(--surface-base);
color: var(--text-primary);
outline: none;
}
.discuss-form-input:focus { border-color: var(--focus-ring); }
.discuss-form-input::placeholder { color: var(--text-subtle); }
.discuss-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 1rem;
height: 36px;
border: 1px solid transparent;
border-radius: 4px;
font: inherit;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
text-decoration: none;
}
.discuss-btn-primary { background: var(--brand-600); color: var(--on-primary); border-color: var(--brand-600); }
.discuss-btn-primary:hover { background: var(--brand-700); border-color: var(--brand-700); }
.discuss-btn-secondary { background: var(--surface-base); color: var(--text-secondary); border-color: var(--border-button); }
.discuss-btn-secondary:hover { background: var(--surface-overlay); }
.discuss-spinner { display: inline-block; }
.discuss-hidden { display: none !important; }
.discuss-flex { display: flex; }
.discuss-flex-col { flex-direction: column; }
.discuss-flex-wrap { flex-wrap: wrap; }
.discuss-flex-1 { flex: 1 1 0%; }
.discuss-flex-shrink-0 { flex-shrink: 0; }
.discuss-items-center { align-items: center; }
.discuss-justify-end { justify-content: flex-end; }
.discuss-gap-2 { gap: 0.5rem; }
.discuss-gap-4 { gap: 1rem; }
.discuss-gap-6 { gap: 1.5rem; }
.discuss-text-sm { font-size: 0.875rem; }
.discuss-text-xs { font-size: 0.75rem; }
.discuss-text-lg { font-size: 1.125rem; }
.discuss-font-semibold { font-weight: 600; }
.discuss-uppercase { text-transform: uppercase; }
.discuss-tracking-wide { letter-spacing: 0.025em; }
.discuss-mb-10 { margin-bottom: 2.5rem; }
/* Dark mode: use whatever selector your site uses,
or pass darkSelector to new DiscussWidget({...}) to have this injected automatically */
[data-theme="dark"] #discuss-comments {
--text-primary: #f8fafc; --text-secondary: #e2e8f0; --text-tertiary: #cbd5e1; --text-muted: #94a3b8; --text-subtle: #64748b;
--surface-base: #111827; --surface-inset: #0a1120; --surface-overlay: #1e293b;
--border-default: #475569; --border-subtle: #334155; --border-control: #475569; --border-button: #334155;
--accent-fg: #93c5fd; --accent-surface: #1e3a5f;
--focus-ring: #93c5fd;
}All configuration is done via environment variables. Copy .env.example to .env and set values before starting the server.
| Variable | Default | Required | Description |
|---|---|---|---|
JWT_SECRET |
(none) | Yes | Signs admin session tokens. Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
ENCRYPTION_KEY |
(none) | Recommended | 32-byte hex key for encrypting SMTP credentials at rest (AES-256-GCM). Generate the same way as JWT_SECRET. If not set, credentials are stored as plaintext. |
PORT |
3000 |
No | Port the server listens on. |
HOST |
127.0.0.1 |
No | Interface the server binds to. The default is localhost-only, which is correct behind a reverse proxy. Don't set to 0.0.0.0 unless you have a specific reason to expose the port directly. |
APP_URL |
(none) | No | Public URL of your Discuss instance, e.g. https://discuss.example.com. Used to generate dashboard links and the logo in notification emails. |
NODE_ENV |
development |
No | Set to production for production deployments. |
The server refuses to start in production if JWT_SECRET is not set.
Settings that vary per site (brand colour, spam controls, SMTP credentials) are configured through the admin dashboard under Domains > Settings, not via environment variables. This lets you run multiple sites from a single Discuss instance with independent configurations.
Discuss sends email notifications when new comments are posted or when someone replies. Configuration is per-domain under Admin > Domains > Settings > Notifications.
- Admin notifications tell you when a new comment arrives or when someone replies to one of your comments.
- Commenter notifications go out automatically when someone replies to a visitor's comment. They don't need to opt in.
Set ENCRYPTION_KEY in .env before entering any SMTP credentials. If you skip it, credentials are stored as plaintext.
Running your own mail server is not recommended. VPS IP ranges are commonly blocklisted and maintaining deliverability is ongoing work. Use a dedicated provider instead.
| Provider | Free tier | Best for |
|---|---|---|
| Gmail | 500/day | Personal projects, easy setup |
| Resend | 3,000/month | Best developer experience |
| Postmark | 100/month | Best deliverability |
| Brevo | 300/day | Generous free tier |
For most self-hosters, Gmail or Resend is the right starting point.
Gmail requires an App Password. Your regular Google account password won't work.
- Enable 2-Step Verification on your Google account.
- Go to Google Account > Security > App passwords.
- Create a new app password and name it "Discuss".
- Use the 16-character password in the SMTP Password field.
| Setting | Value |
|---|---|
| SMTP Host | smtp.gmail.com |
| Port | 587 |
| SSL/TLS | Off (587 uses STARTTLS) |
| Username | your Gmail address |
| Password | the 16-character app password |
- Sign up at resend.com and verify your sending domain.
- Create an API key.
| Setting | Value |
|---|---|
| SMTP Host | smtp.resend.com |
| Port | 465 |
| SSL/TLS | On |
| Username | resend |
| Password | your API key |
- Sign up at postmarkapp.com and create a server.
- Get your SMTP credentials from the server's API Tokens tab.
| Setting | Value |
|---|---|
| SMTP Host | smtp.postmarkapp.com |
| Port | 587 |
| SSL/TLS | Off |
| Username | your server API token |
| Password | your server API token |
Use Send test email in the Notifications tab to verify your settings. The Preview links show you how each template looks before a real comment triggers it.
- Node.js >= 20.6.0
- A Linux server
- A reverse proxy (Nginx recommended)
git clone https://github.com/KarthikeyanKC/discuss.git
cd discuss
npm install --production
npm run setup
cp .env.example .envEdit .env and set JWT_SECRET at minimum.
systemd keeps the server running after reboots and restarts it if it crashes. Create /etc/systemd/system/discuss.service, replacing /var/www/discuss with wherever you cloned the repo and www-data with the user you want it to run as:
[Unit]
Description=Discuss Comment Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/discuss
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
EnvironmentFile=/var/www/discuss/.env
Environment=NODE_ENV=production PORT=3000
[Install]
WantedBy=multi-user.targetThen enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable discuss
sudo systemctl start discussCheck that it's running with sudo systemctl status discuss.
This config proxies requests from your public domain to the Node server on port 3000. Replace discuss.example.com with your domain:
server {
listen 443 ssl;
server_name discuss.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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;
}
}Tip
Setting up SSL with Certbot? Configure the port 80 block only, then run certbot --nginx -d discuss.example.com. Certbot will get the certificate and rewrite your config automatically.
First enable the modules you need:
sudo a2enmod proxy proxy_http headersThen add a VirtualHost. Replace discuss.example.com with your domain and fill in your SSL cert paths:
<VirtualHost *:443>
ServerName discuss.example.com
SSLEngine on
SSLCertificateFile /path/to/cert.pem
SSLCertificateKeyFile /path/to/privkey.pem
AllowEncodedSlashes NoDecode
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:3000/ nocanon
ProxyPassReverse / http://127.0.0.1:3000/
RequestHeader set X-Forwarded-Proto "https"
</VirtualHost>Warning
Both AllowEncodedSlashes NoDecode and nocanon are required. Without them, Apache decodes percent-encoded slashes before proxying and breaks admin deep links on hard reload.
Always back up your database before upgrading:
cp discuss.db discuss.db.bak
git pull origin main
npm install --productionSchema migrations run automatically on startup. No manual SQL is needed.
If running under systemd:
sudo systemctl restart discuss
sudo systemctl status discussNew: Comment editing. Authors can edit their own comments within 15 minutes of posting. The edit button appears right after posting and stays until the window expires. Edits are inline with no page reload. See Comment editing for details on how the stateless token works.
Fix: X-Edit-Token added to Access-Control-Allow-Headers so cross-origin PATCH requests pass the preflight check.
Fix: darkSelector was silently ignored when using the default auto-init (dropping in the script tag with no manual new DiscussWidget() call). Set the selector via data-dark-selector on the container instead:
<div id="discuss-comments" data-dark-selector=".dark"></div>New: darkSelector option. Pass any CSS selector to new DiscussWidget({ darkSelector: '...' }) and the widget switches to its dark colour scheme when that selector matches an ancestor element. No default. The widget is always light unless you opt in. Works with any class-based or attribute-based dark mode toggle on your site.
New: Icon and title slots in the widget. Swap the name, email, and submit button icons with your own SVG, or pass '' to hide them. Override the "Leave a comment" heading with any text you like. Help menu in the admin sidebar shows the current version and links to docs, GitHub, changelog, and update notifications.
Breaking: CSS is no longer bundled inside client.js. It's now a separate client.css file that you need to include yourself. Update your embed snippet:
<link rel="stylesheet" href="https://discuss.example.com/client.css">
<div id="discuss-comments"></div>
<script src="https://discuss.example.com/client.js"></script>If you intentionally leave out the <link> tag the widget still loads, just with no styles. That's fine if you're writing your own. The snippet in your admin dashboard is already updated. No database changes.
New: Trailing-slash URL normalisation, comment export, and admin deep-link support.
Migration: The normalisation migration runs automatically on first server start. It unifies /post/ and /post into a single thread. No data is lost and no manual steps are needed.
Warning
Apache users: This release requires two new directives in your VirtualHost. Without them, hard-reloading admin deep links like /admin/comments returns a 404:
AllowEncodedSlashes NoDecode
ProxyPass / http://127.0.0.1:3000/ nocanonNew: Email notifications for new comments and replies, with per-domain SMTP configuration and encrypted credential storage.
Important
Set ENCRYPTION_KEY in your .env before entering any SMTP credentials. Without it, credentials are stored as plaintext.
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"If you already saved credentials without a key and add one later, you'll need to re-enter them. The stored plaintext won't decrypt correctly under the new key.
If you rename a post's URL after comments have been posted, reassign them manually:
UPDATE comments SET post_url = '/new-path' WHERE post_url = '/old-path';To avoid this situation, pin threads to a stable key using data-url. See Stable thread keys.
| Version | Feature | Status |
|---|---|---|
| v0.6.0 | Import comments | Planned |
| v0.6.0 | Comment reporting | Planned |
| v1.0.0 | Comment favouriting | Planned |
| v1.0.0 | Comment sorting (newest, oldest, most liked) | Planned |
| v1.0.0 | Rate limiting per domain and IP address | Planned |
| v1.1.0 | Comment mentions | Planned |
Have a feature request? Open an issue and let's talk about it.
Contributions are welcome. Here's how to get started:
git clone https://github.com/KarthikeyanKC/discuss.git
cd discuss
npm install
npm run dev # starts the dev server with auto-restart
npm test # run the full unit test suite (Node built-in runner)
npm run test:browser # run browser tests with Playwright (requires `npx playwright install chromium` once)Pre-commit hook
The repo includes a pre-commit hook that runs the test suite, checks that CHANGELOG.md has an entry for the current version, and for minor releases checks that the docs are up to date. Install it once after cloning:
cp scripts/pre-commit.sh .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitIf the hook rejects your commit, read the output. It'll tell you exactly what's missing.
Pull requests
- Keep PRs focused. One thing per PR makes it much easier to review.
- Add a test for any new behaviour. Server-side logic goes in
test/using Node's built-in runner; client-side widget behaviour goes intest/browser/using Playwright. - Add a
CHANGELOG.mdentry under## [Unreleased].
See CONTRIBUTING.md for more detail on conventions and the review process.
See CHANGELOG.md for release history.
MIT © Karthikeyan KC

