A self-hostable, real-time planning poker app for team estimation. Paste your task links, invite the team, and vote on stories together over WebSockets. Works anonymously with no sign-up, or create an account to save sessions and keep history.
Inspired by planningpokeronline.com, kept small and hackable.
Features
- Real-time — everyone sees votes, reveals and resets instantly over WebSockets.
- Hidden votes — cards stay face-down until the facilitator reveals.
- Results — average and vote distribution, with a consensus nudge.
- Rounds from links — paste Jira / Linear / GitHub URLs (or plain lines) and each becomes a round; work through the backlog one story at a time. Add more mid-session, skip, re-activate, or set the agreed estimate when a round finishes.
- Decks — Fibonacci and T-shirt presets, plus fully custom decks. Signed-in users get a saved default deck applied to new sessions.
- Voting timer — the facilitator can start a 60/30/15/5s countdown per round.
- Facilitator hand-off — the facilitator can pass the role to any participant. In an anonymous session it transfers; in an owned session the owner stays in control and the target becomes a co-facilitator (so a teammate can run the meeting).
- Export — download any session's finished rounds and votes as CSV or a plain-text summary, from the session-complete screen or the rounds sidebar.
- Two session modes:
- Anonymous — pick a name, share the room URL. The session runs for 24 h, then self-destructs. The first person in runs it, and the role passes on if they leave.
- Owned (signed in) — the session and its full vote history are saved to your account and persist indefinitely until you delete them. Only you facilitate it.
- Accounts — optional email + password sign-in (bcrypt-hashed, JWT bearer tokens).
- Persistent — accounts, saved sessions, rounds and vote history live in SQLite.
- Single process — one Node server serves the API, the WebSocket, and the web app.
- Lightweight monorepo — three small TypeScript packages, no heavy framework.
Architecture
packages/
shared/ TypeScript types for the WebSocket protocol + REST shapes (used by both sides)
server/ Node + ws + better-sqlite3 — REST API, live rooms, and serves the built web app
web/ Vite + React + TypeScript client (React Router, React Query, react-hook-form)
Live room state (participants, current votes, reveal state, timer) is held in memory for speed; rooms, rounds, recorded votes and accounts are persisted to SQLite so they survive restarts. A live room is lazily loaded from the DB on first join and dropped from memory when it empties.
The web client talks to the backend over REST (/api/*) for auth and room setup, and over
a WebSocket (/ws) for the live session. In dev, Vite proxies both to the server; in the
all-in-one production build the server serves the built client alongside the socket.
Quick start
Requires Node 18+ and pnpm.
pnpm install
pnpm build
pnpm startThen open http://localhost:3000, create a room, and share the URL with your team.
The server creates a SQLite database at ./data/pokerface.db on first run.
Set a custom port with PORT:
PORT=8080 pnpm startConfiguration
All configuration is via environment variables on the server:
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP + WebSocket port. |
DATABASE_PATH |
./data/pokerface.db |
SQLite file location. Point at a persistent volume in production. |
JWT_SECRET |
(random per boot) | Signs session tokens. Set a stable value in production (see below). |
ALLOWED_ORIGINS |
(unset = any) | Comma-separated origin allowlist for the API (CORS) and WebSocket. |
ALLOWED_SIGNUP_DOMAINS |
(unset = any) | Comma-separated email-domain allowlist for registration (e.g. acme.com). |
The web client has one build-time variable, VITE_WS_URL, used only for the split
Railway + Vercel deploy (see below). Unset, the client talks to the same origin at /ws.
Important
If JWT_SECRET is unset, the server generates a new random secret on every boot, so
every account is logged out on each restart/redeploy. If DATABASE_PATH points at
a container's ephemeral disk (the default on most PaaS), accounts, saved sessions and
history are wiped on every redeploy — mount a persistent volume and point
DATABASE_PATH at it.
Development
Runs the server (:3000) and the Vite dev server (:5173) with hot reload:
pnpm devOpen http://localhost:5173 during development.
Deployment (split: Railway + Vercel)
The reference deployment runs the backend on Railway and the frontend on Vercel at https://poker.oprimo.dev.
-
Backend (Railway) — deploys from
railway.json. Railway builds with Nixpacks, starts@pokerface/server, and health-checks/healthz. Railway assigns a public domain likepokerface-production.up.railway.app. -
Frontend (Vercel) — deploys from
vercel.json: builds only@pokerface/weband servespackages/web/distwith an SPA rewrite. Set the build-time env var so the client knows where the backend lives:VITE_WS_URL = wss://<your-railway-domain>/ws
When VITE_WS_URL is unset the client falls back to the same origin, which is
what the all-in-one self-host setup below relies on.
Continuous deployment
Both services auto-deploy on every push to main:
- Railway is connected to this GitHub repo (service source →
main) and rebuilds the backend on push. It needs a Volume (mounted at/data) plus the env varsDATABASE_PATH=/data/pokerface.dbandJWT_SECRETso accounts and history persist. For a split deployment, also setALLOWED_ORIGINSto your frontend origin (e.g.https://poker.oprimo.dev) so the API and WebSocket only accept requests from it. - Vercel is connected to the same repo and rebuilds the frontend on push, using the
VITE_WS_URLproject env var to reach the backend.
Self-hosting (all-in-one)
It's a single stateless Node process — deploy it anywhere that runs Node.
Docker:
FROM node:22-alpine
WORKDIR /app
RUN corepack enable
COPY . .
RUN pnpm install --frozen-lockfile && pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]Behind a reverse proxy (nginx, Caddy, Traefik), make sure WebSocket upgrades on
/ws are forwarded. For a single instance no sticky sessions are needed; if you
scale horizontally you'd need a shared room store (out of scope for this simple app).
Security
Defense is layered across the request lifecycle:
- Origin allowlist — set
ALLOWED_ORIGINS(comma-separated) to restrict both the REST API (CORS) and the WebSocket (anti-CSWSH) to those origins. Unset, any origin is accepted (convenient for the all-in-one self-host, where the app is same-origin anyway). - Rate limiting — register/login (20 / 15 min) and room creation (40 / hour) are
capped per IP; each WebSocket connection is capped at 25 messages/second. Behind a
proxy the client IP is read from
X-Forwarded-For. - Message limits — WebSocket frames are capped at 64 KB and HTTP bodies at 1 MB.
- Security headers — the self-hosted server sends a strict
Content-Security-PolicyplusX-Frame-Options: DENY(clickjacking),X-Content-Type-Options: nosniff,Referrer-PolicyandPermissions-Policy. The Vercel frontend sets the same viavercel.json. - Session secret — always set
JWT_SECRETin production so tokens survive restarts. - Input guards — names, decks and pasted task text are length-clamped, and votes are validated against the active deck before they're counted.
- By construction — all SQL is parameterized (no injection), auth is a Bearer token
in a header rather than a cookie (no CSRF), passwords are bcrypt-hashed, and task URLs
are restricted to
http(s)so they can't becomejavascript:links.
Residual risk to be aware of: a room's share link is a capability URL — anyone with it can join the session and see round titles, statuses and agreed estimates. Room IDs are unguessable 10-char tokens, but treat the link like a secret. Per-person vote history is public for anonymous rooms and private to the signed-in owner for owned rooms. No user email or password data is ever exposed through the room API.
There is no email verification or password reset yet. Registration is open by default;
set ALLOWED_SIGNUP_DOMAINS to your company's email domain(s) to limit who can create an
account, and/or put the deployment behind your VPN/SSO.
Customizing the deck
Edit DEFAULT_DECK in packages/shared/src/index.ts.
License
MIT — do whatever you like.