20 KiB
ECHOBOARD
feedback for everyone - accounts for no one
A self-hosted feedback board where people actually own their voice.
No accounts required. No email harvesting. No surveillance.
Just people telling you what they think - on their terms.
🌍 What is Echoboard?
Echoboard is a feedback platform you host yourself. People visit your board, submit feature requests or bug reports, vote on what matters to them, and leave comments - all without creating an account, handing over an email address, or agreeing to be tracked.
It's built on a simple idea: feedback should be accessible to everyone, not just people willing to hand over personal data to yet another platform. The barrier to participation is zero. Show up, speak, leave. Your voice counts the same whether you're anonymous or not.
If someone wants more permanence - to keep their posts across devices or browsers - they can optionally register a passkey. No password, no email. Just a biometric or device PIN. Their identity belongs to them, stored on their own device, not in your database.
🔒 Identity, privacy, and why we don't want your email
Most platforms demand an email address before you're allowed to speak. That email becomes a leash - used for marketing, sold to data brokers, leaked in breaches, and weaponized for account recovery attacks. Echoboard doesn't ask for it because Echoboard doesn't need it. Nobody needs it. It was never about verification - it was about control.
Identity in Echoboard works as a spectrum. You choose how much of yourself to reveal, and you can change your mind at any time:
Just show up - the moment you visit, a random token is stored in your browser cookie. That's your identity. No form, no click-through, no consent banner for data you never gave. You can vote, comment, and submit posts immediately. If you clear your cookies, that identity is gone - and that's fine. The feedback you left behind still stands on its own. Your words matter more than your name.
Save a recovery phrase - if you want a safety net without committing to anything permanent, you can generate a six-word recovery phrase from the settings page. Write it on a sticky note, put it in a password manager, tattoo it on your arm - your call. If cookies get wiped, you type those six words and you're back. The phrase is hashed with bcrypt and looked up via a blind index - even we can't read it. It's single-use and expires after 90 days, so there's no permanent token sitting in a database waiting to be stolen.
Register a passkey - for people who want real persistence across browsers and devices. Passkeys use WebAuthn - your phone's fingerprint reader, your laptop's face unlock, or a hardware security key. The private key never leaves your device. We store a public key that can verify you, but can't impersonate you. No password to forget, phish, or leak. No email to harvest.
At every level, display names are encrypted at rest with AES-256-GCM. Lookups happen through blind indexes (HMAC-SHA256) so the database never stores plaintext names alongside user records. There is no email column in the users table. There is no phone number column. There is no "real name" column. These fields don't exist because we made a deliberate choice not to build the infrastructure of surveillance, even if we promise not to use it. The safest data is data that was never collected.
The people who use your feedback board are helping you build better things. The least you can do is not make them pay for that privilege with their personal information.
🗳️ How people use it
As a visitor, you land on a board and immediately see what others have submitted. You can vote on things you care about, leave comments, file your own feature requests or bug reports - all without signing up for anything. A browser cookie ties your activity together for the session. Close the tab and come back later, you're still you (as long as cookies persist).
If you want persistence, you can register a passkey from the settings page. This lets you keep your identity across browsers and devices. You get a username, a profile, and the ability to upload an avatar. Or if your browser doesn't support passkeys, you can generate a recovery phrase - six words you write down somewhere safe that let you get back to your account if cookies get cleared.
As an admin or team member, you manage boards, respond to feedback, track statuses, merge duplicates, and keep things organized. The admin dashboard gives you full control over every aspect of the platform without touching a terminal.
✊ Why self-host?
Because your community's feedback shouldn't live on someone else's server, feeding someone else's business model. When you self-host Echoboard:
- The data lives on your infrastructure, under your control
- No third party reads, mines, or monetizes the feedback
- No vendor lock-in - the code is CC0, do whatever you want with it
- No "free tier" that mysteriously degrades when you need it most
- No dark patterns nudging people toward paid plans
- Your users interact with YOUR instance, not a platform that treats them as product
The tools people use to communicate should belong to the people using them. Not to shareholders, not to VCs, not to ad networks. Echoboard is a small step in that direction.
📦 Features
For everyone
- Submit feature requests and bug reports without any account
- Vote on posts with configurable budgets (so no one person dominates)
- Comment with markdown, @mentions, emoji reactions, and file attachments
- Dark and light themes that follow system preference
- Full-text search across all boards
- Similar post detection (so you find existing requests before duplicating)
- RSS feeds for every board
- Push notifications for status changes and new posts
- Recovery codes for people who can't use passkeys
- Keyboard accessible and screen reader friendly throughout
For admins and teams
- Dashboard with stats, post management, and bulk actions
- Custom statuses per board (not just open/closed - whatever fits your workflow)
- Roadmap and changelog pages with scheduled publishing
- Team system - invite admins and moderators without them needing email accounts
- Granular locking - lock post edits, individual comments, entire threads, or voting
- Post merging for duplicates with vote consolidation
- Edit history with rollback for both posts and comments
- Embed widget you can drop into any external site
- Webhooks for status changes, new posts, and comments
- View counts on every post
- Data export in CSV and JSON
Infrastructure
- Runs in Docker with a single
docker compose up -d - PostgreSQL for storage, no external services needed
- Plugin system - upload zip plugins through the dashboard, no restart required
- ALTCHA proof-of-work spam protection (no CAPTCHAs bothering your users)
- Field-level encryption for personally identifiable data
- HSTS, CSP, and all the security headers you'd expect
- Automatic database migrations on startup
🚀 Getting started
1. Clone and configure
git clone https://git.lashman.live/lashman/echoboard.git
cd echoboard
cp .env.example .env
Open .env in your editor and fill in:
Database password - set POSTGRES_PASSWORD to something random.
Encryption keys - generate five secrets, one for each of APP_MASTER_KEY, APP_BLIND_INDEX_KEY, TOKEN_SECRET, JWT_SECRET, and ALTCHA_HMAC_KEY:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Run that five times, paste each output into the corresponding variable.
Push notification keys - generate VAPID keys:
npx web-push generate-vapid-keys
Paste the public and private keys into VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.
Domain settings - set WEBAUTHN_RP_ID to your domain (e.g. feedback.example.com) and WEBAUTHN_ORIGIN to the full URL (e.g. https://feedback.example.com).
2. Start it
docker compose up -d
That's it. Two containers come up - the app and a PostgreSQL database. Migrations run automatically.
3. Create your admin account
Visit /admin in your browser. Since no admin exists yet, you'll see a setup screen where you pick an email and password. This screen disappears forever after the first admin is created.
4. Set up HTTPS
Passkeys require HTTPS to work. Put a reverse proxy in front of Echoboard. With nginx:
server {
listen 443 ssl;
server_name feedback.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:3000;
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;
client_max_body_size 50m;
}
}
🔄 Updating
git pull
docker compose up -d --build
Database migrations run automatically on startup. Your data, uploads, and plugins are preserved.
💾 Data and backups
All persistent data lives in directories next to the compose file:
| Directory | Contents |
|---|---|
./data/postgres/ |
Database files |
./uploads/ |
User avatars and file attachments |
./plugins-installed/ |
Uploaded plugin files and assets |
Back these up however you normally back things up. The database can also be dumped with docker compose exec db pg_dump -U echoboard echoboard > backup.sql.
👥 Team system
Echoboard supports multiple team members without anyone needing an email account.
Super admin - the person who set up the instance. Full access to everything. Can invite admins and moderators, manage branding, configure webhooks, install plugins. Cannot be removed.
Admin - can do most things the super admin can, except change branding or install plugins. Can invite moderators. Sees their own invitees in the team list.
Moderator - can respond to posts, change statuses, pin, merge, lock, and manage tags and categories. Cannot configure boards, webhooks, or invite others.
To add a team member: go to Team in the admin panel, click Invite, choose a role and expiry. Share the invite link. The person claims it, picks a display name, and secures their account with a passkey or recovery phrase. No email involved.
Each team member gets a display name and optional team title (like "Product Lead" or "Support") that shows alongside their responses in the public feed.
🧩 Plugin system
Plugins extend Echoboard without modifying the core code. They're zip files uploaded through the admin dashboard - no server restart, no command line.
What plugins can do
- Add API routes - mounted under
/api/v1/plugins/<pluginId>/your-path - React to events - get called when posts are created, statuses change, comments are added
- Store data - a key-value store backed by the database, scoped per plugin
- Read the database - full read/write access to all models via Prisma
- Declare config fields - the admin sees a proper form (text, password, number, boolean, select) instead of raw JSON
Plugin structure
A plugin is a zip containing at minimum:
manifest.json
index.js
Optional:
assets/ (static files - fonts, images, CSS)
manifest.json
{
"name": "my-plugin",
"version": "1.0.0",
"description": "What this plugin does",
"author": "you",
"entryPoint": "index.js",
"configSchema": [
{
"key": "apiUrl",
"type": "text",
"label": "API endpoint",
"placeholder": "https://api.example.com",
"required": true
},
{
"key": "apiKey",
"type": "password",
"label": "API key"
},
{
"key": "syncInterval",
"type": "number",
"label": "Sync interval (minutes)"
},
{
"key": "enabled",
"type": "boolean",
"label": "Enable automatic sync"
},
{
"key": "region",
"type": "select",
"label": "Region",
"options": [
{ "value": "us", "label": "United States" },
{ "value": "eu", "label": "Europe" }
]
}
]
}
The name must be lowercase alphanumeric with dashes. configSchema is optional - if omitted, the admin sees a raw JSON editor for configuration.
index.js
The entry point must export a descriptor object (ESM export default):
export default {
// API routes (optional)
routes: [
{
method: "POST",
path: "/sync",
async handler(req, reply, ctx) {
// req and reply are Fastify objects
// ctx is the plugin context (see below)
reply.send({ ok: true })
}
},
{
method: "GET",
path: "/status",
async handler(req, reply, ctx) {
const lastRun = await ctx.store.get("lastRun")
reply.send({ lastRun })
}
}
],
// Event handlers (optional)
events: {
async post_created(data, ctx) {
// data: { postId, title, type, boardId, boardSlug }
ctx.logger.info({ postId: data.postId }, "new post")
},
async status_changed(data, ctx) {
// data: { postId, title, boardId, from, to }
if (data.to === "PLANNED") {
// do something when a post moves to planned
}
},
async comment_added(data, ctx) {
// data: { commentId, postId, body }
}
},
// Called when the plugin is enabled (optional)
async onEnable(ctx) {
ctx.logger.info("plugin enabled")
// good place for initial sync, setup, etc.
},
// Called when the plugin is disabled (optional)
async onDisable(ctx) {
ctx.logger.info("plugin disabled")
// cleanup intervals, connections, etc.
}
}
Plugin context
Every handler receives a ctx object with:
| Property | Type | Description |
|---|---|---|
ctx.prisma |
PrismaClient | Full database access - query any model |
ctx.store |
PluginStore | Key-value storage scoped to this plugin |
ctx.config |
object | The plugin's config (set by admin in dashboard) |
ctx.logger |
Logger | Structured logger (info, warn, error) |
ctx.pluginId |
string | The plugin's database ID |
Plugin store
The store is a simple key-value interface backed by the database:
await ctx.store.set("lastSync", new Date().toISOString())
const lastSync = await ctx.store.get("lastSync")
await ctx.store.delete("lastSync")
const all = await ctx.store.list() // [{ key, value }]
Values are stored as JSON - you can store strings, numbers, objects, arrays.
Available events
| Event | Fired when | Payload |
|---|---|---|
post_created |
A new post is submitted | { postId, title, type, boardId, boardSlug } |
status_changed |
An admin changes a post's status | { postId, title, boardId, from, to } |
comment_added |
A comment is posted | { commentId, postId, body } |
Static assets
Put files in an assets/ directory in your zip. They're served at:
/api/v1/admin/plugins/<pluginId>/assets/<filename>
Allowed file types: CSS, JS, PNG, JPG, GIF, SVG, WebP, WOFF, WOFF2, TTF, EOT, JSON.
Plugin ideas
- Slack/Discord notifications - post to a channel when new feedback comes in or statuses change
- Linear/Jira sync - create tickets from high-voted posts, sync status back
- GitHub issues mirror - two-way sync between boards and GitHub repos
- Email digest - weekly summary of new feedback and status changes
- Auto-triage - move posts to categories based on keywords
- Vote threshold actions - auto-promote posts to "planned" when they hit N votes
- Gitea/Forgejo sync - create boards from repos (included as example plugin)
- Analytics export - push feedback data to your analytics pipeline
- Webhook relay - forward events to services that don't support webhooks natively
Security note
Plugins run with full server access. They can read and write any data in the database, make network requests, and execute arbitrary code. Only install plugins you trust, written by people you trust. This is the same trust model as WordPress plugins, Grafana plugins, or any other self-hosted extension system.
Only the super admin can install, enable, disable, or delete plugins.
🛠️ Development
Prerequisites
- Node.js 20+
- PostgreSQL 14+ (or use Docker)
- npm
Setup
git clone https://git.lashman.live/lashman/echoboard.git
cd echoboard
npm install
cp .env.example .env
Edit .env:
- Set
WEBAUTHN_RP_ID=localhost - Set
WEBAUTHN_ORIGIN=http://localhost:5173 - Point
DATABASE_URLat a local PostgreSQL instance - Generate and fill in the encryption keys (same process as production)
Running
npm run dev
This starts:
- API server on port 3001 (Fastify + tsx watch)
- Frontend dev server on port 5173 (Vite with HMR)
The Vite dev server proxies API requests to port 3001 automatically.
Project structure
echoboard/
packages/
api/ Backend (Fastify + Prisma)
src/
cli/ Admin creation CLI
cron/ Scheduled jobs
lib/ Shared utilities
middleware/ Auth, security
plugins/ Plugin loader, registry, context
routes/ API endpoints
admin/ Admin-only endpoints
services/ Encryption, push, webhooks
prisma/
schema.prisma Database schema
migrations/ SQL migrations
web/ Frontend (React + Vite)
src/
components/ Reusable UI components
hooks/ React hooks (auth, admin, toast, etc.)
i18n/ Translations
lib/ API client, utilities
pages/ Route pages
admin/ Admin panel pages
public/ Static assets (service worker, embed script)
plugins/ Example plugins
docker-compose.yml Production deployment
Dockerfile Multi-stage build
Key architectural decisions
Anonymous-first identity - users get a random token cookie on first visit. No signup wall, no email collection. Passkeys are an optional upgrade, not a requirement.
Field-level encryption - display names and other PII are encrypted with AES-256-GCM before storage. Blind indexes (HMAC-SHA256) enable lookups without exposing plaintext.
ALTCHA proof-of-work - instead of CAPTCHAs (which are inaccessible, annoying, and feed surveillance companies), spam is deterred by making the client's browser solve a small computational puzzle. Invisible to users, hostile to bots.
Plugin descriptor pattern - plugins export a descriptor object instead of directly registering Fastify routes. This lets the system load and unload plugins at runtime without restarting the server.
Granular locking - instead of a single "lock" toggle, posts have three independent locks (post edits, thread/comments, voting). Comments can also be individually locked. This gives moderators precise control without over-restricting discussion.
📄 License
CC0-1.0
This work is dedicated to the public domain. You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission. No attribution required.
Because tools for collective communication shouldn't be enclosed behind intellectual property fences. Take it, use it, make it yours.