Vibecode Tally
track this build8 phases, 18 steps, beginner friendly0%A form builder that stores responses and sends webhooks/email is a classic one-sitting/weekend app.
You are building a lean indie version of Tally.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Tally · indie build
A personal form builder on your own server: create forms and fields in an admin, publish each at a public URL that works without JavaScript, store answers in SQLite with file uploads on disk, get an email or webhook per response, and export CSV. Your respondents' data stays in your file.
Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22 with Express and better-sqlite3 | many routes and forms; a thin framework earns its place here |
| Rendering | Server-rendered HTML, no client framework | public forms must submit with JavaScript disabled |
| Uploads | Disk under uploads/<form-slug>/ | one server, one disk, simple backups |
| Hosting | A VPS behind Caddy | uploads need persistent disk |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **One long random admin token** · free
- Why: The admin is protected by a single token, no user accounts.
- Get it: openssl rand -base64 32 into .env as ADMIN_TOKEN. Store it in your password manager.
- [ ] **SMTP credentials for response notifications (optional)** (optional) · free tiers exist
- Why: Phase 7 emails you each response. Any SMTP provider works; without one, use the webhook or just read the admin.
- Get it: Fastmail, Postmark, Resend or your mail host: create an app password or SMTP credential, note host, port, user, password.
- [ ] **A webhook URL to receive responses (optional)** (optional) · free
- Why: The other notification path: a Zapier or Make hook, or webhook.site while testing.
- Get it: Create one at webhook.site to test with; replace with the real target later.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. Uploads need disk that persists across deploys.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A public address you own, so links you share never break when a provider changes.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir forms && cd forms && git init && npm init -y && npm pkg set type=module
npm install express@4 better-sqlite3@13
mkdir -p data/uploads && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Conditional logic, partial submissions, payments.
- A template gallery.
- Team workspaces and custom domains per form.
- beautiful editor
- unlimited free usage
- partial submissions
- custom domains
- integrations
- team workspaces
- anti-spam
If one of those is essential to you, that is the reason to keep paying for Tally, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Tally
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a personal form builder to replace Tally. Build it in phases, in the
order below. Do not write the whole app in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22, Express and better-sqlite3, server-rendered pages, deployed on a VPS
behind Caddy. No frontend framework, no build step.
- A single `ADMIN_TOKEN` from `.env`. No user accounts, no multi-tenancy.
- Uploads to `uploads/<form-slug>/` on disk, not to object storage.
### Data model (create this before Phase 1)
- `forms`: id, slug (unique), title, description, accent, published (bool),
notify_email (nullable), webhook_url (nullable), created_at
- `fields`: id, form_id, position, kind ('text' | 'textarea' | 'email' | 'select'
| 'checkbox' | 'file'), label, help, required (bool), options (JSON, for select)
- `responses`: id, form_id, submitted_at, ip_hash, user_agent_class
- `answers`: id, response_id, field_id, value, file_path (nullable)
Answers go in their own table rather than a JSON blob on `responses`. A form's
fields change over time, and a blob keyed by label silently loses the history the
moment someone renames a question. Store `field_id` and keep deleted fields as
soft-deleted rows so old responses still render.
### Phase 1 · Form definition and admin auth
Build: `/admin` behind the `ADMIN_TOKEN` (constant-time compare, set as an
HttpOnly cookie after entry, never in the URL). CRUD for forms: title,
description, slug, accent, publish toggle.
Done when: a wrong token is refused, a right one persists across a page load, the
token never appears in a URL or a log line, and a form can be created, renamed
and deleted.
Do not build yet: fields, the public page, submissions.
### Phase 2 · Field editor
Build: adding, editing, reordering (explicit up/down buttons before drag · they
work on mobile and are testable) and soft-deleting fields. Each kind carries its
own settings; `select` carries its options.
Done when: a form with one of every field kind saves and reloads identically,
reordering persists, and soft-deleting a field hides it from the public form
while leaving it visible in past responses.
### Phase 3 · Public form rendering
Build: `/f/<slug>` · a single-column form, one accent color, one question visually
per block, mobile-first, dark mode, correct labels and `for` attributes, and
autocomplete hints on email fields. Unpublished forms return 404, not a preview.
The form must submit and validate without JavaScript.
Done when: the page scores no accessibility violations, every field is keyboard
reachable with a visible focus ring, submitting with JS disabled works, and an
unpublished slug 404s.
Do not build yet: storing anything.
### Phase 4 · Submission and validation
Build: the POST handler. Validate server-side against the field definitions ·
never trust the client copy of the rules. Required fields, email shape, select
values drawn from the stored options, length caps. On failure, re-render the form
with the user's answers preserved and the errors shown inline; losing a long
answer to a validation error is the single worst thing this app can do.
Done when: a valid submission stores one response with one answer row per field,
an invalid one re-renders with every previously typed value intact, and a forged
select value outside the stored options is rejected.
### Phase 5 · File uploads
Build: the `file` field kind · streamed to `uploads/<form-slug>/` with a
generated filename that never reuses the uploaded name, a 10 MB cap enforced
while streaming (not after buffering), a MIME check against the decoded header
rather than the extension, and EXIF stripped from images.
Done when: an 11 MB file is refused before it is fully read, a `.php` renamed to
`.png` is refused, two uploads with the same original name coexist, and the
uploads directory is not web-served directly.
### Phase 6 · Spam controls
Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill
time of 3 seconds carried as a signed timestamp, and a per-IP rate limit of 10
submissions per hour stored in SQLite so it survives a restart.
Done when: a filled honeypot shows the normal thank-you and stores nothing, a
sub-3-second submission is refused, and the eleventh submission in an hour is
refused while another IP still succeeds.
### Phase 7 · Notifications and export
Build: per form, an optional email of each response over SMTP from `.env`, and an
optional webhook POST of the response JSON. Both must be fire-and-forget with a
timeout · a dead SMTP host must never make the respondent wait or lose their
submission. Then the admin response view: a sortable table per form and a
streamed CSV export.
Done when: a submission still succeeds and stores correctly when SMTP is
unreachable and the webhook times out, and a 10,000-row CSV exports without
memory growth.
### Phase 8 · Deploy
Build: a `/healthz` endpoint, a nightly backup covering the database and the
uploads directory, the Caddy snippet, a systemd unit, and the README.
Done when: a restore from backup brings back both responses and their uploaded
files.
### Out of scope (and why)
- Conditional logic and question branching, partial submissions, and payments.
- A template gallery.
- Team workspaces and custom domains per form.
- Their editor. Tally's free tier is genuinely generous, and the honest reason to
build this is data ownership, not cost · say so in the README.
### README must contain
- The `.env` keys, the Caddy snippet, and where the database and uploads live.
- The backup command, noting that uploads are not inside the database.
===== AGENTS.md =====
# Agent instructions · Tally indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, Server-rendered HTML, no client framework, Disk under uploads/<form-slug>/, A VPS behind Caddy. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
===== BUILD_PLAN.md =====
# Build plan · Tally
A personal form builder on your own server: create forms and fields in an admin, publish each at a public URL that works without JavaScript, store answers in SQLite with file uploads on disk, get an email or webhook per response, and export CSV. Your respondents' data stays in your file.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Form definition and admin auth
An admin you can log into with the token, and forms you can create.
### Steps
1. Create the project and the tables
forms (id, slug, title, description, accent, published, notify_email, webhook_url), fields (id, form_id, position, kind, label, help, required, options JSON, deleted), responses (id, form_id, submitted_at, ip_hash, user_agent_class), answers (id, response_id, field_id, value, file_path).
```sh
mkdir forms && cd forms && git init && npm init -y && npm pkg set type=module
npm install express@4 better-sqlite3@13
mkdir -p data/uploads && cp .env.example .env
```
2. Implement token login
POST the token; compare with a constant-time function; set an HttpOnly cookie. Never accept the token in a URL.
3. Build form CRUD in /admin
### Done when
- [ ] A wrong token is refused
- [ ] A right one persists across a page load as a cookie
- [ ] The token never appears in a URL or a log line
- [ ] A form can be created, renamed and deleted
## Phase 2 · Field editor
Every field kind, reorderable, soft-deleted so old responses keep rendering.
### Steps
1. Add, edit and soft-delete fields with up/down buttons for order
Kinds: text, textarea, email, select, checkbox, file. Explicit buttons work on mobile and are testable; drag can come later.
2. Store select options as JSON and validate them on save
At least one option, no duplicates, trimmed.
### Done when
- [ ] A form with one of every kind saves and reloads identically
- [ ] Reordering persists
- [ ] A soft-deleted field disappears from the public form but still shows in past responses
## Phase 3 · Public form rendering
Accessible, single column, works without JavaScript, unpublished forms 404.
### Steps
1. Build GET /f/:slug
Labels with for attributes, one question per block, single column, mobile-first. Unpublished forms return 404.
2. Add the accent custom property, dark mode and a designed thank-you page
prefers-color-scheme with the same custom properties overridden.
### Done when
- [ ] No accessibility violations
- [ ] Every field keyboard reachable with a focus ring
- [ ] Submitting with JavaScript disabled works
- [ ] An unpublished slug returns 404
## Phase 4 · Submission and validation
Validate on the server against the stored definitions and never lose what someone typed.
### Steps
1. Implement POST /f/:slug with server-side validation against the stored fields
Required, email shape, select values drawn from the stored options, length caps. Never trust the client copy of the rules.
2. Preserve every typed value on a validation error
Re-render with inline errors and the answers intact; losing a long answer is the worst thing this app can do.
3. Store one answers row per field with the field_id
So a renamed or soft-deleted field still renders in past responses.
### Done when
- [ ] A valid submission stores one response with one answer per field
- [ ] An invalid one re-renders with every typed value intact
- [ ] A forged select value outside the stored options is rejected
## Phase 5 · File uploads
Safe uploads: streamed, capped, sniffed, stripped.
### Steps
1. Stream multipart to UPLOAD_DIR/<slug>/ under a generated name
Cap at MAX_UPLOAD_MB while streaming; check MIME against the decoded header; strip EXIF from images with sharp.
```sh
npm install busboy@1 sharp@0.35.3
```
2. Never serve UPLOAD_DIR directly; serve via an admin-only route
### Done when
- [ ] An oversize file is refused before it is fully read
- [ ] A .php renamed to .png is refused
- [ ] Two uploads with the same original name coexist
- [ ] Uploads are not reachable without admin auth
## Phase 6 · Spam controls
Honeypot, minimum fill time, per-IP limits in SQLite.
### Steps
1. Add a CSS-hidden honeypot and a signed minimum-fill timestamp requiring 3 seconds
2. Rate limit 10 submissions per IP per hour in a SQLite table
Survives restarts, unlike memory.
### Done when
- [ ] A filled honeypot shows thanks and stores nothing
- [ ] A sub-3-second submission is refused
- [ ] The eleventh in an hour is refused while another IP succeeds
## Phase 7 · Notifications and export
Email and webhook that can never make a respondent wait, and a streamed CSV.
### Steps
1. Send email over SMTP_URL and POST the webhook, fire-and-forget with a timeout
```sh
npm install nodemailer@6
```
2. Build the admin response table and streamed CSV export
### Done when
- [ ] A submission still stores and shows thanks with SMTP unreachable and the webhook timing out
- [ ] A real email arrives readable on a phone
- [ ] A 10,000-row CSV exports without memory growth
## Phase 8 · Deploy
Live, backed up including uploads, documented.
### Steps
1. Add /healthz, systemd, Caddy and a backup of data/
Files: `deploy/forms.service`, `Caddyfile`
2. Write the README
.env keys, the Caddy snippet, where the database and uploads live, and the honest line that Tally's free tier is generous and the reason to build this is data ownership.
Files: `README.md`
### Done when
- [ ] A restore brings back both responses and files
- [ ] A reader goes from clone to a published form using only the README
## Not in this build
- Conditional logic, partial submissions, payments.
- A template gallery.
- Team workspaces and custom domains per form.
## After v1, if you want it
- Conditional visibility for one field based on another's value
- A Slack notification adapter
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/forms.db
# Required. Where files land, per form slug.
UPLOAD_DIR=./data/uploads
# Required · secret. openssl rand -base64 32. The only admin credential.
ADMIN_TOKEN=base64-from-openssl
# Required. Public base URL.
SITE_URL=https://forms.yourdomain.com
# Optional · secret. SMTP connection string from your provider.
SMTP_URL=smtps://user:pass@smtp.fastmail.com:465
# Optional. From address for notifications.
NOTIFY_FROM=forms@yourdomain.com
# Optional. Per-file cap, enforced while streaming.
MAX_UPLOAD_MB=10
You are building a lean indie version of Tally.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Tally · indie build
A personal form builder on your own server: create forms and fields in an admin, publish each at a public URL that works without JavaScript, store answers in SQLite with file uploads on disk, get an email or webhook per response, and export CSV. Your respondents' data stays in your file.
Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22 with Express and better-sqlite3 | many routes and forms; a thin framework earns its place here |
| Rendering | Server-rendered HTML, no client framework | public forms must submit with JavaScript disabled |
| Uploads | Disk under uploads/<form-slug>/ | one server, one disk, simple backups |
| Hosting | A VPS behind Caddy | uploads need persistent disk |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **One long random admin token** · free
- Why: The admin is protected by a single token, no user accounts.
- Get it: openssl rand -base64 32 into .env as ADMIN_TOKEN. Store it in your password manager.
- [ ] **SMTP credentials for response notifications (optional)** (optional) · free tiers exist
- Why: Phase 7 emails you each response. Any SMTP provider works; without one, use the webhook or just read the admin.
- Get it: Fastmail, Postmark, Resend or your mail host: create an app password or SMTP credential, note host, port, user, password.
- [ ] **A webhook URL to receive responses (optional)** (optional) · free
- Why: The other notification path: a Zapier or Make hook, or webhook.site while testing.
- Get it: Create one at webhook.site to test with; replace with the real target later.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. Uploads need disk that persists across deploys.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A public address you own, so links you share never break when a provider changes.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir forms && cd forms && git init && npm init -y && npm pkg set type=module
npm install express@4 better-sqlite3@13
mkdir -p data/uploads && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Conditional logic, partial submissions, payments.
- A template gallery.
- Team workspaces and custom domains per form.
- beautiful editor
- unlimited free usage
- partial submissions
- custom domains
- integrations
- team workspaces
- anti-spam
If one of those is essential to you, that is the reason to keep paying for Tally, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Tally
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a personal form builder to replace Tally. Build it in phases, in the
order below. Do not write the whole app in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22, Express and better-sqlite3, server-rendered pages, deployed on a VPS
behind Caddy. No frontend framework, no build step.
- A single `ADMIN_TOKEN` from `.env`. No user accounts, no multi-tenancy.
- Uploads to `uploads/<form-slug>/` on disk, not to object storage.
### Data model (create this before Phase 1)
- `forms`: id, slug (unique), title, description, accent, published (bool),
notify_email (nullable), webhook_url (nullable), created_at
- `fields`: id, form_id, position, kind ('text' | 'textarea' | 'email' | 'select'
| 'checkbox' | 'file'), label, help, required (bool), options (JSON, for select)
- `responses`: id, form_id, submitted_at, ip_hash, user_agent_class
- `answers`: id, response_id, field_id, value, file_path (nullable)
Answers go in their own table rather than a JSON blob on `responses`. A form's
fields change over time, and a blob keyed by label silently loses the history the
moment someone renames a question. Store `field_id` and keep deleted fields as
soft-deleted rows so old responses still render.
### Phase 1 · Form definition and admin auth
Build: `/admin` behind the `ADMIN_TOKEN` (constant-time compare, set as an
HttpOnly cookie after entry, never in the URL). CRUD for forms: title,
description, slug, accent, publish toggle.
Done when: a wrong token is refused, a right one persists across a page load, the
token never appears in a URL or a log line, and a form can be created, renamed
and deleted.
Do not build yet: fields, the public page, submissions.
### Phase 2 · Field editor
Build: adding, editing, reordering (explicit up/down buttons before drag · they
work on mobile and are testable) and soft-deleting fields. Each kind carries its
own settings; `select` carries its options.
Done when: a form with one of every field kind saves and reloads identically,
reordering persists, and soft-deleting a field hides it from the public form
while leaving it visible in past responses.
### Phase 3 · Public form rendering
Build: `/f/<slug>` · a single-column form, one accent color, one question visually
per block, mobile-first, dark mode, correct labels and `for` attributes, and
autocomplete hints on email fields. Unpublished forms return 404, not a preview.
The form must submit and validate without JavaScript.
Done when: the page scores no accessibility violations, every field is keyboard
reachable with a visible focus ring, submitting with JS disabled works, and an
unpublished slug 404s.
Do not build yet: storing anything.
### Phase 4 · Submission and validation
Build: the POST handler. Validate server-side against the field definitions ·
never trust the client copy of the rules. Required fields, email shape, select
values drawn from the stored options, length caps. On failure, re-render the form
with the user's answers preserved and the errors shown inline; losing a long
answer to a validation error is the single worst thing this app can do.
Done when: a valid submission stores one response with one answer row per field,
an invalid one re-renders with every previously typed value intact, and a forged
select value outside the stored options is rejected.
### Phase 5 · File uploads
Build: the `file` field kind · streamed to `uploads/<form-slug>/` with a
generated filename that never reuses the uploaded name, a 10 MB cap enforced
while streaming (not after buffering), a MIME check against the decoded header
rather than the extension, and EXIF stripped from images.
Done when: an 11 MB file is refused before it is fully read, a `.php` renamed to
`.png` is refused, two uploads with the same original name coexist, and the
uploads directory is not web-served directly.
### Phase 6 · Spam controls
Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill
time of 3 seconds carried as a signed timestamp, and a per-IP rate limit of 10
submissions per hour stored in SQLite so it survives a restart.
Done when: a filled honeypot shows the normal thank-you and stores nothing, a
sub-3-second submission is refused, and the eleventh submission in an hour is
refused while another IP still succeeds.
### Phase 7 · Notifications and export
Build: per form, an optional email of each response over SMTP from `.env`, and an
optional webhook POST of the response JSON. Both must be fire-and-forget with a
timeout · a dead SMTP host must never make the respondent wait or lose their
submission. Then the admin response view: a sortable table per form and a
streamed CSV export.
Done when: a submission still succeeds and stores correctly when SMTP is
unreachable and the webhook times out, and a 10,000-row CSV exports without
memory growth.
### Phase 8 · Deploy
Build: a `/healthz` endpoint, a nightly backup covering the database and the
uploads directory, the Caddy snippet, a systemd unit, and the README.
Done when: a restore from backup brings back both responses and their uploaded
files.
### Out of scope (and why)
- Conditional logic and question branching, partial submissions, and payments.
- A template gallery.
- Team workspaces and custom domains per form.
- Their editor. Tally's free tier is genuinely generous, and the honest reason to
build this is data ownership, not cost · say so in the README.
### README must contain
- The `.env` keys, the Caddy snippet, and where the database and uploads live.
- The backup command, noting that uploads are not inside the database.
===== AGENTS.md =====
# Agent instructions · Tally indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, Server-rendered HTML, no client framework, Disk under uploads/<form-slug>/, A VPS behind Caddy. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
===== BUILD_PLAN.md =====
# Build plan · Tally
A personal form builder on your own server: create forms and fields in an admin, publish each at a public URL that works without JavaScript, store answers in SQLite with file uploads on disk, get an email or webhook per response, and export CSV. Your respondents' data stays in your file.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Form definition and admin auth
An admin you can log into with the token, and forms you can create.
### Steps
1. Create the project and the tables
forms (id, slug, title, description, accent, published, notify_email, webhook_url), fields (id, form_id, position, kind, label, help, required, options JSON, deleted), responses (id, form_id, submitted_at, ip_hash, user_agent_class), answers (id, response_id, field_id, value, file_path).
```sh
mkdir forms && cd forms && git init && npm init -y && npm pkg set type=module
npm install express@4 better-sqlite3@13
mkdir -p data/uploads && cp .env.example .env
```
2. Implement token login
POST the token; compare with a constant-time function; set an HttpOnly cookie. Never accept the token in a URL.
3. Build form CRUD in /admin
### Done when
- [ ] A wrong token is refused
- [ ] A right one persists across a page load as a cookie
- [ ] The token never appears in a URL or a log line
- [ ] A form can be created, renamed and deleted
## Phase 2 · Field editor
Every field kind, reorderable, soft-deleted so old responses keep rendering.
### Steps
1. Add, edit and soft-delete fields with up/down buttons for order
Kinds: text, textarea, email, select, checkbox, file. Explicit buttons work on mobile and are testable; drag can come later.
2. Store select options as JSON and validate them on save
At least one option, no duplicates, trimmed.
### Done when
- [ ] A form with one of every kind saves and reloads identically
- [ ] Reordering persists
- [ ] A soft-deleted field disappears from the public form but still shows in past responses
## Phase 3 · Public form rendering
Accessible, single column, works without JavaScript, unpublished forms 404.
### Steps
1. Build GET /f/:slug
Labels with for attributes, one question per block, single column, mobile-first. Unpublished forms return 404.
2. Add the accent custom property, dark mode and a designed thank-you page
prefers-color-scheme with the same custom properties overridden.
### Done when
- [ ] No accessibility violations
- [ ] Every field keyboard reachable with a focus ring
- [ ] Submitting with JavaScript disabled works
- [ ] An unpublished slug returns 404
## Phase 4 · Submission and validation
Validate on the server against the stored definitions and never lose what someone typed.
### Steps
1. Implement POST /f/:slug with server-side validation against the stored fields
Required, email shape, select values drawn from the stored options, length caps. Never trust the client copy of the rules.
2. Preserve every typed value on a validation error
Re-render with inline errors and the answers intact; losing a long answer is the worst thing this app can do.
3. Store one answers row per field with the field_id
So a renamed or soft-deleted field still renders in past responses.
### Done when
- [ ] A valid submission stores one response with one answer per field
- [ ] An invalid one re-renders with every typed value intact
- [ ] A forged select value outside the stored options is rejected
## Phase 5 · File uploads
Safe uploads: streamed, capped, sniffed, stripped.
### Steps
1. Stream multipart to UPLOAD_DIR/<slug>/ under a generated name
Cap at MAX_UPLOAD_MB while streaming; check MIME against the decoded header; strip EXIF from images with sharp.
```sh
npm install busboy@1 sharp@0.35.3
```
2. Never serve UPLOAD_DIR directly; serve via an admin-only route
### Done when
- [ ] An oversize file is refused before it is fully read
- [ ] A .php renamed to .png is refused
- [ ] Two uploads with the same original name coexist
- [ ] Uploads are not reachable without admin auth
## Phase 6 · Spam controls
Honeypot, minimum fill time, per-IP limits in SQLite.
### Steps
1. Add a CSS-hidden honeypot and a signed minimum-fill timestamp requiring 3 seconds
2. Rate limit 10 submissions per IP per hour in a SQLite table
Survives restarts, unlike memory.
### Done when
- [ ] A filled honeypot shows thanks and stores nothing
- [ ] A sub-3-second submission is refused
- [ ] The eleventh in an hour is refused while another IP succeeds
## Phase 7 · Notifications and export
Email and webhook that can never make a respondent wait, and a streamed CSV.
### Steps
1. Send email over SMTP_URL and POST the webhook, fire-and-forget with a timeout
```sh
npm install nodemailer@6
```
2. Build the admin response table and streamed CSV export
### Done when
- [ ] A submission still stores and shows thanks with SMTP unreachable and the webhook timing out
- [ ] A real email arrives readable on a phone
- [ ] A 10,000-row CSV exports without memory growth
## Phase 8 · Deploy
Live, backed up including uploads, documented.
### Steps
1. Add /healthz, systemd, Caddy and a backup of data/
Files: `deploy/forms.service`, `Caddyfile`
2. Write the README
.env keys, the Caddy snippet, where the database and uploads live, and the honest line that Tally's free tier is generous and the reason to build this is data ownership.
Files: `README.md`
### Done when
- [ ] A restore brings back both responses and files
- [ ] A reader goes from clone to a published form using only the README
## Not in this build
- Conditional logic, partial submissions, payments.
- A template gallery.
- Team workspaces and custom domains per form.
## After v1, if you want it
- Conditional visibility for one field based on another's value
- A Slack notification adapter
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/forms.db
# Required. Where files land, per form slug.
UPLOAD_DIR=./data/uploads
# Required · secret. openssl rand -base64 32. The only admin credential.
ADMIN_TOKEN=base64-from-openssl
# Required. Public base URL.
SITE_URL=https://forms.yourdomain.com
# Optional · secret. SMTP connection string from your provider.
SMTP_URL=smtps://user:pass@smtp.fastmail.com:465
# Optional. From address for notifications.
NOTIFY_FROM=forms@yourdomain.com
# Optional. Per-file cap, enforced while streaming.
MAX_UPLOAD_MB=10
You are building a production product version of Tally.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# Tally · product brief
## Problem
A form builder that stores responses and sends webhooks/email is a classic one-sitting/weekend app.
## Product outcome
A form service for one person or a small team: any number of forms, uploads handled safely, respondent data never leaving your server.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- hosted backend
- database
- form renderer
- file upload storage if needed
- email/webhook service
## Explicit non-goals for v1
- Conditional logic, partial submissions, payments.
- A template gallery.
- Team workspaces and custom domains per form.
- beautiful editor
- unlimited free usage
- partial submissions
- custom domains
- integrations
- team workspaces
- anti-spam
## Success criteria
- Every validation path verified with a fixture
- Upload rejection verified with a fake image and an oversize file
- One restore drill including uploads performed and dated
- Notifications verified fire-and-forget with SMTP down
===== BRIEF.md =====
# Build brief · Tally
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a personal form builder to replace Tally. Build it in phases, in the
order below. Do not write the whole app in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22, Express and better-sqlite3, server-rendered pages, deployed on a VPS
behind Caddy. No frontend framework, no build step.
- A single `ADMIN_TOKEN` from `.env`. No user accounts, no multi-tenancy.
- Uploads to `uploads/<form-slug>/` on disk, not to object storage.
### Data model (create this before Phase 1)
- `forms`: id, slug (unique), title, description, accent, published (bool),
notify_email (nullable), webhook_url (nullable), created_at
- `fields`: id, form_id, position, kind ('text' | 'textarea' | 'email' | 'select'
| 'checkbox' | 'file'), label, help, required (bool), options (JSON, for select)
- `responses`: id, form_id, submitted_at, ip_hash, user_agent_class
- `answers`: id, response_id, field_id, value, file_path (nullable)
Answers go in their own table rather than a JSON blob on `responses`. A form's
fields change over time, and a blob keyed by label silently loses the history the
moment someone renames a question. Store `field_id` and keep deleted fields as
soft-deleted rows so old responses still render.
### Phase 1 · Form definition and admin auth
Build: `/admin` behind the `ADMIN_TOKEN` (constant-time compare, set as an
HttpOnly cookie after entry, never in the URL). CRUD for forms: title,
description, slug, accent, publish toggle.
Done when: a wrong token is refused, a right one persists across a page load, the
token never appears in a URL or a log line, and a form can be created, renamed
and deleted.
Do not build yet: fields, the public page, submissions.
### Phase 2 · Field editor
Build: adding, editing, reordering (explicit up/down buttons before drag · they
work on mobile and are testable) and soft-deleting fields. Each kind carries its
own settings; `select` carries its options.
Done when: a form with one of every field kind saves and reloads identically,
reordering persists, and soft-deleting a field hides it from the public form
while leaving it visible in past responses.
### Phase 3 · Public form rendering
Build: `/f/<slug>` · a single-column form, one accent color, one question visually
per block, mobile-first, dark mode, correct labels and `for` attributes, and
autocomplete hints on email fields. Unpublished forms return 404, not a preview.
The form must submit and validate without JavaScript.
Done when: the page scores no accessibility violations, every field is keyboard
reachable with a visible focus ring, submitting with JS disabled works, and an
unpublished slug 404s.
Do not build yet: storing anything.
### Phase 4 · Submission and validation
Build: the POST handler. Validate server-side against the field definitions ·
never trust the client copy of the rules. Required fields, email shape, select
values drawn from the stored options, length caps. On failure, re-render the form
with the user's answers preserved and the errors shown inline; losing a long
answer to a validation error is the single worst thing this app can do.
Done when: a valid submission stores one response with one answer row per field,
an invalid one re-renders with every previously typed value intact, and a forged
select value outside the stored options is rejected.
### Phase 5 · File uploads
Build: the `file` field kind · streamed to `uploads/<form-slug>/` with a
generated filename that never reuses the uploaded name, a 10 MB cap enforced
while streaming (not after buffering), a MIME check against the decoded header
rather than the extension, and EXIF stripped from images.
Done when: an 11 MB file is refused before it is fully read, a `.php` renamed to
`.png` is refused, two uploads with the same original name coexist, and the
uploads directory is not web-served directly.
### Phase 6 · Spam controls
Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill
time of 3 seconds carried as a signed timestamp, and a per-IP rate limit of 10
submissions per hour stored in SQLite so it survives a restart.
Done when: a filled honeypot shows the normal thank-you and stores nothing, a
sub-3-second submission is refused, and the eleventh submission in an hour is
refused while another IP still succeeds.
### Phase 7 · Notifications and export
Build: per form, an optional email of each response over SMTP from `.env`, and an
optional webhook POST of the response JSON. Both must be fire-and-forget with a
timeout · a dead SMTP host must never make the respondent wait or lose their
submission. Then the admin response view: a sortable table per form and a
streamed CSV export.
Done when: a submission still succeeds and stores correctly when SMTP is
unreachable and the webhook times out, and a 10,000-row CSV exports without
memory growth.
### Phase 8 · Deploy
Build: a `/healthz` endpoint, a nightly backup covering the database and the
uploads directory, the Caddy snippet, a systemd unit, and the README.
Done when: a restore from backup brings back both responses and their uploaded
files.
### Out of scope (and why)
- Conditional logic and question branching, partial submissions, and payments.
- A template gallery.
- Team workspaces and custom domains per form.
- Their editor. Tally's free tier is genuinely generous, and the honest reason to
build this is data ownership, not cost · say so in the README.
### README must contain
- The `.env` keys, the Caddy snippet, and where the database and uploads live.
- The backup command, noting that uploads are not inside the database.
===== ARCHITECTURE.md =====
# Architecture · Tally
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22 with Express and better-sqlite3 | many routes and forms; a thin framework earns its place here |
| Rendering | Server-rendered HTML, no client framework | public forms must submit with JavaScript disabled |
| Uploads | Disk under uploads/<form-slug>/ | one server, one disk, simple backups |
| Hosting | A VPS behind Caddy | uploads need persistent disk |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Admin | token auth, form and field CRUD | Add real accounts later behind the same routes |
| Renderer | public forms and validation | A JavaScript-enhanced form could sit on top; the POST contract stays |
| Storage | responses, answers, uploads | Object storage behind the same write/read functions |
| Notify | email and webhook, fire-and-forget | Add Slack as another adapter |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `PORT` · required · Any free port.
- `DATABASE_PATH` · required · SQLite file.
- `UPLOAD_DIR` · required · Where files land, per form slug.
- `ADMIN_TOKEN` · required, secret · openssl rand -base64 32. The only admin credential.
- `SITE_URL` · required · Public base URL.
- `SMTP_URL` · optional, secret · SMTP connection string from your provider.
- `NOTIFY_FROM` · optional · From address for notifications.
- `MAX_UPLOAD_MB` · optional · Per-file cap, enforced while streaming.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · Tally product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express and better-sqlite3, Server-rendered HTML, no client framework, Disk under uploads/<form-slug>/, A VPS behind Caddy.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
===== MILESTONES.md =====
# Delivery milestones · Tally
Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Form definition and admin auth
An admin you can log into with the token, and forms you can create.
### Steps
1. Create the project and the tables
forms (id, slug, title, description, accent, published, notify_email, webhook_url), fields (id, form_id, position, kind, label, help, required, options JSON, deleted), responses (id, form_id, submitted_at, ip_hash, user_agent_class), answers (id, response_id, field_id, value, file_path).
```sh
mkdir forms && cd forms && git init && npm init -y && npm pkg set type=module
npm install express@4 better-sqlite3@13
mkdir -p data/uploads && cp .env.example .env
```
2. Implement token login
POST the token; compare with a constant-time function; set an HttpOnly cookie. Never accept the token in a URL.
3. Build form CRUD in /admin
### Done when
- [ ] A wrong token is refused
- [ ] A right one persists across a page load as a cookie
- [ ] The token never appears in a URL or a log line
- [ ] A form can be created, renamed and deleted
## M2 · Field editor
Every field kind, reorderable, soft-deleted so old responses keep rendering.
### Steps
1. Add, edit and soft-delete fields with up/down buttons for order
Kinds: text, textarea, email, select, checkbox, file. Explicit buttons work on mobile and are testable; drag can come later.
2. Store select options as JSON and validate them on save
At least one option, no duplicates, trimmed.
### Done when
- [ ] A form with one of every kind saves and reloads identically
- [ ] Reordering persists
- [ ] A soft-deleted field disappears from the public form but still shows in past responses
## M3 · Public form rendering
Accessible, single column, works without JavaScript, unpublished forms 404.
### Steps
1. Build GET /f/:slug
Labels with for attributes, one question per block, single column, mobile-first. Unpublished forms return 404.
2. Add the accent custom property, dark mode and a designed thank-you page
prefers-color-scheme with the same custom properties overridden.
### Done when
- [ ] No accessibility violations
- [ ] Every field keyboard reachable with a focus ring
- [ ] Submitting with JavaScript disabled works
- [ ] An unpublished slug returns 404
## M4 · Submission and validation
Validate on the server against the stored definitions and never lose what someone typed.
### Steps
1. Implement POST /f/:slug with server-side validation against the stored fields
Required, email shape, select values drawn from the stored options, length caps. Never trust the client copy of the rules.
2. Preserve every typed value on a validation error
Re-render with inline errors and the answers intact; losing a long answer is the worst thing this app can do.
3. Store one answers row per field with the field_id
So a renamed or soft-deleted field still renders in past responses.
### Done when
- [ ] A valid submission stores one response with one answer per field
- [ ] An invalid one re-renders with every typed value intact
- [ ] A forged select value outside the stored options is rejected
## M5 · File uploads
Safe uploads: streamed, capped, sniffed, stripped.
### Steps
1. Stream multipart to UPLOAD_DIR/<slug>/ under a generated name
Cap at MAX_UPLOAD_MB while streaming; check MIME against the decoded header; strip EXIF from images with sharp.
```sh
npm install busboy@1 sharp@0.35.3
```
2. Never serve UPLOAD_DIR directly; serve via an admin-only route
### Done when
- [ ] An oversize file is refused before it is fully read
- [ ] A .php renamed to .png is refused
- [ ] Two uploads with the same original name coexist
- [ ] Uploads are not reachable without admin auth
## M6 · Spam controls
Honeypot, minimum fill time, per-IP limits in SQLite.
### Steps
1. Add a CSS-hidden honeypot and a signed minimum-fill timestamp requiring 3 seconds
2. Rate limit 10 submissions per IP per hour in a SQLite table
Survives restarts, unlike memory.
### Done when
- [ ] A filled honeypot shows thanks and stores nothing
- [ ] A sub-3-second submission is refused
- [ ] The eleventh in an hour is refused while another IP succeeds
## M7 · Notifications and export
Email and webhook that can never make a respondent wait, and a streamed CSV.
### Steps
1. Send email over SMTP_URL and POST the webhook, fire-and-forget with a timeout
```sh
npm install nodemailer@6
```
2. Build the admin response table and streamed CSV export
### Done when
- [ ] A submission still stores and shows thanks with SMTP unreachable and the webhook timing out
- [ ] A real email arrives readable on a phone
- [ ] A 10,000-row CSV exports without memory growth
## M8 · Deploy
Live, backed up including uploads, documented.
### Steps
1. Add /healthz, systemd, Caddy and a backup of data/
Files: `deploy/forms.service`, `Caddyfile`
2. Write the README
.env keys, the Caddy snippet, where the database and uploads live, and the honest line that Tally's free tier is generous and the reason to build this is data ownership.
Files: `README.md`
### Done when
- [ ] A restore brings back both responses and files
- [ ] A reader goes from clone to a published form using only the README
## M9 · Operate it like a product (production only)
Only for the product-builder path: know when the form server is down, never lose the database, and keep the server patched.
### Steps
1. Add a /healthz endpoint and an external uptime check against it
Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices.
2. Write structured request logs and rotate them
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
3. Back the SQLite file up off the machine nightly and test a restore
SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it.
```sh
sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'"
rclone copy /tmp/app-$(date +%F).db remote:backups/
```
4. Lock the box down
Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure.
### Done when
- [ ] Stopping the service triggers an uptime alert within a few minutes
- [ ] A restore from last night's backup contains yesterday's data
- [ ] A port scan from another machine shows only 22, 80 and 443
===== OPERATIONS.md =====
# Operations · Tally
## Backup
tar of data/ nightly off the box.
## Restore
Extract, start, check one form and one upload.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
Uptime on /healthz; alert on a spike in refused submissions.
## Incident checklist
Spam wave: tighten limits, bulk-delete. Compromise: rebuild, restore data/, rotate ADMIN_TOKEN and SMTP credentials.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] Every validation path verified with a fixture
- [ ] Upload rejection verified with a fake image and an oversize file
- [ ] One restore drill including uploads performed and dated
- [ ] Notifications verified fire-and-forget with SMTP down
## Launch constraint
Do not market omitted Tally capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/forms.db
# Required. Where files land, per form slug.
UPLOAD_DIR=./data/uploads
# Required · secret. openssl rand -base64 32. The only admin credential.
ADMIN_TOKEN=base64-from-openssl
# Required. Public base URL.
SITE_URL=https://forms.yourdomain.com
# Optional · secret. SMTP connection string from your provider.
SMTP_URL=smtps://user:pass@smtp.fastmail.com:465
# Optional. From address for notifications.
NOTIFY_FROM=forms@yourdomain.com
# Optional. Per-file cap, enforced while streaming.
MAX_UPLOAD_MB=10
# Tally · indie build A personal form builder on your own server: create forms and fields in an admin, publish each at a public URL that works without JavaScript, store answers in SQLite with file uploads on disk, get an email or webhook per response, and export CSV. Your respondents' data stays in your file. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3 | many routes and forms; a thin framework earns its place here | | Rendering | Server-rendered HTML, no client framework | public forms must submit with JavaScript disabled | | Uploads | Disk under uploads/<form-slug>/ | one server, one disk, simple backups | | Hosting | A VPS behind Caddy | uploads need persistent disk | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **One long random admin token** · free - Why: The admin is protected by a single token, no user accounts. - Get it: openssl rand -base64 32 into .env as ADMIN_TOKEN. Store it in your password manager. - [ ] **SMTP credentials for response notifications (optional)** (optional) · free tiers exist - Why: Phase 7 emails you each response. Any SMTP provider works; without one, use the webhook or just read the admin. - Get it: Fastmail, Postmark, Resend or your mail host: create an app password or SMTP credential, note host, port, user, password. - [ ] **A webhook URL to receive responses (optional)** (optional) · free - Why: The other notification path: a Zapier or Make hook, or webhook.site while testing. - Get it: Create one at webhook.site to test with; replace with the real target later. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. Uploads need disk that persists across deploys. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: A public address you own, so links you share never break when a provider changes. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir forms && cd forms && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 mkdir -p data/uploads && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Conditional logic, partial submissions, payments. - A template gallery. - Team workspaces and custom domains per form. - beautiful editor - unlimited free usage - partial submissions - custom domains - integrations - team workspaces - anti-spam If one of those is essential to you, that is the reason to keep paying for Tally, and the README should say so rather than pretend.
# Build brief · Tally
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a personal form builder to replace Tally. Build it in phases, in the
order below. Do not write the whole app in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22, Express and better-sqlite3, server-rendered pages, deployed on a VPS
behind Caddy. No frontend framework, no build step.
- A single `ADMIN_TOKEN` from `.env`. No user accounts, no multi-tenancy.
- Uploads to `uploads/<form-slug>/` on disk, not to object storage.
### Data model (create this before Phase 1)
- `forms`: id, slug (unique), title, description, accent, published (bool),
notify_email (nullable), webhook_url (nullable), created_at
- `fields`: id, form_id, position, kind ('text' | 'textarea' | 'email' | 'select'
| 'checkbox' | 'file'), label, help, required (bool), options (JSON, for select)
- `responses`: id, form_id, submitted_at, ip_hash, user_agent_class
- `answers`: id, response_id, field_id, value, file_path (nullable)
Answers go in their own table rather than a JSON blob on `responses`. A form's
fields change over time, and a blob keyed by label silently loses the history the
moment someone renames a question. Store `field_id` and keep deleted fields as
soft-deleted rows so old responses still render.
### Phase 1 · Form definition and admin auth
Build: `/admin` behind the `ADMIN_TOKEN` (constant-time compare, set as an
HttpOnly cookie after entry, never in the URL). CRUD for forms: title,
description, slug, accent, publish toggle.
Done when: a wrong token is refused, a right one persists across a page load, the
token never appears in a URL or a log line, and a form can be created, renamed
and deleted.
Do not build yet: fields, the public page, submissions.
### Phase 2 · Field editor
Build: adding, editing, reordering (explicit up/down buttons before drag · they
work on mobile and are testable) and soft-deleting fields. Each kind carries its
own settings; `select` carries its options.
Done when: a form with one of every field kind saves and reloads identically,
reordering persists, and soft-deleting a field hides it from the public form
while leaving it visible in past responses.
### Phase 3 · Public form rendering
Build: `/f/<slug>` · a single-column form, one accent color, one question visually
per block, mobile-first, dark mode, correct labels and `for` attributes, and
autocomplete hints on email fields. Unpublished forms return 404, not a preview.
The form must submit and validate without JavaScript.
Done when: the page scores no accessibility violations, every field is keyboard
reachable with a visible focus ring, submitting with JS disabled works, and an
unpublished slug 404s.
Do not build yet: storing anything.
### Phase 4 · Submission and validation
Build: the POST handler. Validate server-side against the field definitions ·
never trust the client copy of the rules. Required fields, email shape, select
values drawn from the stored options, length caps. On failure, re-render the form
with the user's answers preserved and the errors shown inline; losing a long
answer to a validation error is the single worst thing this app can do.
Done when: a valid submission stores one response with one answer row per field,
an invalid one re-renders with every previously typed value intact, and a forged
select value outside the stored options is rejected.
### Phase 5 · File uploads
Build: the `file` field kind · streamed to `uploads/<form-slug>/` with a
generated filename that never reuses the uploaded name, a 10 MB cap enforced
while streaming (not after buffering), a MIME check against the decoded header
rather than the extension, and EXIF stripped from images.
Done when: an 11 MB file is refused before it is fully read, a `.php` renamed to
`.png` is refused, two uploads with the same original name coexist, and the
uploads directory is not web-served directly.
### Phase 6 · Spam controls
Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill
time of 3 seconds carried as a signed timestamp, and a per-IP rate limit of 10
submissions per hour stored in SQLite so it survives a restart.
Done when: a filled honeypot shows the normal thank-you and stores nothing, a
sub-3-second submission is refused, and the eleventh submission in an hour is
refused while another IP still succeeds.
### Phase 7 · Notifications and export
Build: per form, an optional email of each response over SMTP from `.env`, and an
optional webhook POST of the response JSON. Both must be fire-and-forget with a
timeout · a dead SMTP host must never make the respondent wait or lose their
submission. Then the admin response view: a sortable table per form and a
streamed CSV export.
Done when: a submission still succeeds and stores correctly when SMTP is
unreachable and the webhook times out, and a 10,000-row CSV exports without
memory growth.
### Phase 8 · Deploy
Build: a `/healthz` endpoint, a nightly backup covering the database and the
uploads directory, the Caddy snippet, a systemd unit, and the README.
Done when: a restore from backup brings back both responses and their uploaded
files.
### Out of scope (and why)
- Conditional logic and question branching, partial submissions, and payments.
- A template gallery.
- Team workspaces and custom domains per form.
- Their editor. Tally's free tier is genuinely generous, and the honest reason to
build this is data ownership, not cost · say so in the README.
### README must contain
- The `.env` keys, the Caddy snippet, and where the database and uploads live.
- The backup command, noting that uploads are not inside the database.# Agent instructions · Tally indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, Server-rendered HTML, no client framework, Disk under uploads/<form-slug>/, A VPS behind Caddy. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
# Build plan · Tally A personal form builder on your own server: create forms and fields in an admin, publish each at a public URL that works without JavaScript, store answers in SQLite with file uploads on disk, get an email or webhook per response, and export CSV. Your respondents' data stays in your file. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Form definition and admin auth An admin you can log into with the token, and forms you can create. ### Steps 1. Create the project and the tables forms (id, slug, title, description, accent, published, notify_email, webhook_url), fields (id, form_id, position, kind, label, help, required, options JSON, deleted), responses (id, form_id, submitted_at, ip_hash, user_agent_class), answers (id, response_id, field_id, value, file_path). ```sh mkdir forms && cd forms && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 mkdir -p data/uploads && cp .env.example .env ``` 2. Implement token login POST the token; compare with a constant-time function; set an HttpOnly cookie. Never accept the token in a URL. 3. Build form CRUD in /admin ### Done when - [ ] A wrong token is refused - [ ] A right one persists across a page load as a cookie - [ ] The token never appears in a URL or a log line - [ ] A form can be created, renamed and deleted ## Phase 2 · Field editor Every field kind, reorderable, soft-deleted so old responses keep rendering. ### Steps 1. Add, edit and soft-delete fields with up/down buttons for order Kinds: text, textarea, email, select, checkbox, file. Explicit buttons work on mobile and are testable; drag can come later. 2. Store select options as JSON and validate them on save At least one option, no duplicates, trimmed. ### Done when - [ ] A form with one of every kind saves and reloads identically - [ ] Reordering persists - [ ] A soft-deleted field disappears from the public form but still shows in past responses ## Phase 3 · Public form rendering Accessible, single column, works without JavaScript, unpublished forms 404. ### Steps 1. Build GET /f/:slug Labels with for attributes, one question per block, single column, mobile-first. Unpublished forms return 404. 2. Add the accent custom property, dark mode and a designed thank-you page prefers-color-scheme with the same custom properties overridden. ### Done when - [ ] No accessibility violations - [ ] Every field keyboard reachable with a focus ring - [ ] Submitting with JavaScript disabled works - [ ] An unpublished slug returns 404 ## Phase 4 · Submission and validation Validate on the server against the stored definitions and never lose what someone typed. ### Steps 1. Implement POST /f/:slug with server-side validation against the stored fields Required, email shape, select values drawn from the stored options, length caps. Never trust the client copy of the rules. 2. Preserve every typed value on a validation error Re-render with inline errors and the answers intact; losing a long answer is the worst thing this app can do. 3. Store one answers row per field with the field_id So a renamed or soft-deleted field still renders in past responses. ### Done when - [ ] A valid submission stores one response with one answer per field - [ ] An invalid one re-renders with every typed value intact - [ ] A forged select value outside the stored options is rejected ## Phase 5 · File uploads Safe uploads: streamed, capped, sniffed, stripped. ### Steps 1. Stream multipart to UPLOAD_DIR/<slug>/ under a generated name Cap at MAX_UPLOAD_MB while streaming; check MIME against the decoded header; strip EXIF from images with sharp. ```sh npm install busboy@1 sharp@0.35.3 ``` 2. Never serve UPLOAD_DIR directly; serve via an admin-only route ### Done when - [ ] An oversize file is refused before it is fully read - [ ] A .php renamed to .png is refused - [ ] Two uploads with the same original name coexist - [ ] Uploads are not reachable without admin auth ## Phase 6 · Spam controls Honeypot, minimum fill time, per-IP limits in SQLite. ### Steps 1. Add a CSS-hidden honeypot and a signed minimum-fill timestamp requiring 3 seconds 2. Rate limit 10 submissions per IP per hour in a SQLite table Survives restarts, unlike memory. ### Done when - [ ] A filled honeypot shows thanks and stores nothing - [ ] A sub-3-second submission is refused - [ ] The eleventh in an hour is refused while another IP succeeds ## Phase 7 · Notifications and export Email and webhook that can never make a respondent wait, and a streamed CSV. ### Steps 1. Send email over SMTP_URL and POST the webhook, fire-and-forget with a timeout ```sh npm install nodemailer@6 ``` 2. Build the admin response table and streamed CSV export ### Done when - [ ] A submission still stores and shows thanks with SMTP unreachable and the webhook timing out - [ ] A real email arrives readable on a phone - [ ] A 10,000-row CSV exports without memory growth ## Phase 8 · Deploy Live, backed up including uploads, documented. ### Steps 1. Add /healthz, systemd, Caddy and a backup of data/ Files: `deploy/forms.service`, `Caddyfile` 2. Write the README .env keys, the Caddy snippet, where the database and uploads live, and the honest line that Tally's free tier is generous and the reason to build this is data ownership. Files: `README.md` ### Done when - [ ] A restore brings back both responses and files - [ ] A reader goes from clone to a published form using only the README ## Not in this build - Conditional logic, partial submissions, payments. - A template gallery. - Team workspaces and custom domains per form. ## After v1, if you want it - Conditional visibility for one field based on another's value - A Slack notification adapter
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/forms.db # Required. Where files land, per form slug. UPLOAD_DIR=./data/uploads # Required · secret. openssl rand -base64 32. The only admin credential. ADMIN_TOKEN=base64-from-openssl # Required. Public base URL. SITE_URL=https://forms.yourdomain.com # Optional · secret. SMTP connection string from your provider. SMTP_URL=smtps://user:pass@smtp.fastmail.com:465 # Optional. From address for notifications. NOTIFY_FROM=forms@yourdomain.com # Optional. Per-file cap, enforced while streaming. MAX_UPLOAD_MB=10
# Tally · product brief ## Problem A form builder that stores responses and sends webhooks/email is a classic one-sitting/weekend app. ## Product outcome A form service for one person or a small team: any number of forms, uploads handled safely, respondent data never leaving your server. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - hosted backend - database - form renderer - file upload storage if needed - email/webhook service ## Explicit non-goals for v1 - Conditional logic, partial submissions, payments. - A template gallery. - Team workspaces and custom domains per form. - beautiful editor - unlimited free usage - partial submissions - custom domains - integrations - team workspaces - anti-spam ## Success criteria - Every validation path verified with a fixture - Upload rejection verified with a fake image and an oversize file - One restore drill including uploads performed and dated - Notifications verified fire-and-forget with SMTP down
# Build brief · Tally
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a personal form builder to replace Tally. Build it in phases, in the
order below. Do not write the whole app in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22, Express and better-sqlite3, server-rendered pages, deployed on a VPS
behind Caddy. No frontend framework, no build step.
- A single `ADMIN_TOKEN` from `.env`. No user accounts, no multi-tenancy.
- Uploads to `uploads/<form-slug>/` on disk, not to object storage.
### Data model (create this before Phase 1)
- `forms`: id, slug (unique), title, description, accent, published (bool),
notify_email (nullable), webhook_url (nullable), created_at
- `fields`: id, form_id, position, kind ('text' | 'textarea' | 'email' | 'select'
| 'checkbox' | 'file'), label, help, required (bool), options (JSON, for select)
- `responses`: id, form_id, submitted_at, ip_hash, user_agent_class
- `answers`: id, response_id, field_id, value, file_path (nullable)
Answers go in their own table rather than a JSON blob on `responses`. A form's
fields change over time, and a blob keyed by label silently loses the history the
moment someone renames a question. Store `field_id` and keep deleted fields as
soft-deleted rows so old responses still render.
### Phase 1 · Form definition and admin auth
Build: `/admin` behind the `ADMIN_TOKEN` (constant-time compare, set as an
HttpOnly cookie after entry, never in the URL). CRUD for forms: title,
description, slug, accent, publish toggle.
Done when: a wrong token is refused, a right one persists across a page load, the
token never appears in a URL or a log line, and a form can be created, renamed
and deleted.
Do not build yet: fields, the public page, submissions.
### Phase 2 · Field editor
Build: adding, editing, reordering (explicit up/down buttons before drag · they
work on mobile and are testable) and soft-deleting fields. Each kind carries its
own settings; `select` carries its options.
Done when: a form with one of every field kind saves and reloads identically,
reordering persists, and soft-deleting a field hides it from the public form
while leaving it visible in past responses.
### Phase 3 · Public form rendering
Build: `/f/<slug>` · a single-column form, one accent color, one question visually
per block, mobile-first, dark mode, correct labels and `for` attributes, and
autocomplete hints on email fields. Unpublished forms return 404, not a preview.
The form must submit and validate without JavaScript.
Done when: the page scores no accessibility violations, every field is keyboard
reachable with a visible focus ring, submitting with JS disabled works, and an
unpublished slug 404s.
Do not build yet: storing anything.
### Phase 4 · Submission and validation
Build: the POST handler. Validate server-side against the field definitions ·
never trust the client copy of the rules. Required fields, email shape, select
values drawn from the stored options, length caps. On failure, re-render the form
with the user's answers preserved and the errors shown inline; losing a long
answer to a validation error is the single worst thing this app can do.
Done when: a valid submission stores one response with one answer row per field,
an invalid one re-renders with every previously typed value intact, and a forged
select value outside the stored options is rejected.
### Phase 5 · File uploads
Build: the `file` field kind · streamed to `uploads/<form-slug>/` with a
generated filename that never reuses the uploaded name, a 10 MB cap enforced
while streaming (not after buffering), a MIME check against the decoded header
rather than the extension, and EXIF stripped from images.
Done when: an 11 MB file is refused before it is fully read, a `.php` renamed to
`.png` is refused, two uploads with the same original name coexist, and the
uploads directory is not web-served directly.
### Phase 6 · Spam controls
Build: a honeypot field hidden with CSS (never `type=hidden`), a minimum fill
time of 3 seconds carried as a signed timestamp, and a per-IP rate limit of 10
submissions per hour stored in SQLite so it survives a restart.
Done when: a filled honeypot shows the normal thank-you and stores nothing, a
sub-3-second submission is refused, and the eleventh submission in an hour is
refused while another IP still succeeds.
### Phase 7 · Notifications and export
Build: per form, an optional email of each response over SMTP from `.env`, and an
optional webhook POST of the response JSON. Both must be fire-and-forget with a
timeout · a dead SMTP host must never make the respondent wait or lose their
submission. Then the admin response view: a sortable table per form and a
streamed CSV export.
Done when: a submission still succeeds and stores correctly when SMTP is
unreachable and the webhook times out, and a 10,000-row CSV exports without
memory growth.
### Phase 8 · Deploy
Build: a `/healthz` endpoint, a nightly backup covering the database and the
uploads directory, the Caddy snippet, a systemd unit, and the README.
Done when: a restore from backup brings back both responses and their uploaded
files.
### Out of scope (and why)
- Conditional logic and question branching, partial submissions, and payments.
- A template gallery.
- Team workspaces and custom domains per form.
- Their editor. Tally's free tier is genuinely generous, and the honest reason to
build this is data ownership, not cost · say so in the README.
### README must contain
- The `.env` keys, the Caddy snippet, and where the database and uploads live.
- The backup command, noting that uploads are not inside the database.# Architecture · Tally ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3 | many routes and forms; a thin framework earns its place here | | Rendering | Server-rendered HTML, no client framework | public forms must submit with JavaScript disabled | | Uploads | Disk under uploads/<form-slug>/ | one server, one disk, simple backups | | Hosting | A VPS behind Caddy | uploads need persistent disk | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Admin | token auth, form and field CRUD | Add real accounts later behind the same routes | | Renderer | public forms and validation | A JavaScript-enhanced form could sit on top; the POST contract stays | | Storage | responses, answers, uploads | Object storage behind the same write/read functions | | Notify | email and webhook, fire-and-forget | Add Slack as another adapter | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port. - `DATABASE_PATH` · required · SQLite file. - `UPLOAD_DIR` · required · Where files land, per form slug. - `ADMIN_TOKEN` · required, secret · openssl rand -base64 32. The only admin credential. - `SITE_URL` · required · Public base URL. - `SMTP_URL` · optional, secret · SMTP connection string from your provider. - `NOTIFY_FROM` · optional · From address for notifications. - `MAX_UPLOAD_MB` · optional · Per-file cap, enforced while streaming. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Tally product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express and better-sqlite3, Server-rendered HTML, no client framework, Disk under uploads/<form-slug>/, A VPS behind Caddy. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone.
# Delivery milestones · Tally Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Form definition and admin auth An admin you can log into with the token, and forms you can create. ### Steps 1. Create the project and the tables forms (id, slug, title, description, accent, published, notify_email, webhook_url), fields (id, form_id, position, kind, label, help, required, options JSON, deleted), responses (id, form_id, submitted_at, ip_hash, user_agent_class), answers (id, response_id, field_id, value, file_path). ```sh mkdir forms && cd forms && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 mkdir -p data/uploads && cp .env.example .env ``` 2. Implement token login POST the token; compare with a constant-time function; set an HttpOnly cookie. Never accept the token in a URL. 3. Build form CRUD in /admin ### Done when - [ ] A wrong token is refused - [ ] A right one persists across a page load as a cookie - [ ] The token never appears in a URL or a log line - [ ] A form can be created, renamed and deleted ## M2 · Field editor Every field kind, reorderable, soft-deleted so old responses keep rendering. ### Steps 1. Add, edit and soft-delete fields with up/down buttons for order Kinds: text, textarea, email, select, checkbox, file. Explicit buttons work on mobile and are testable; drag can come later. 2. Store select options as JSON and validate them on save At least one option, no duplicates, trimmed. ### Done when - [ ] A form with one of every kind saves and reloads identically - [ ] Reordering persists - [ ] A soft-deleted field disappears from the public form but still shows in past responses ## M3 · Public form rendering Accessible, single column, works without JavaScript, unpublished forms 404. ### Steps 1. Build GET /f/:slug Labels with for attributes, one question per block, single column, mobile-first. Unpublished forms return 404. 2. Add the accent custom property, dark mode and a designed thank-you page prefers-color-scheme with the same custom properties overridden. ### Done when - [ ] No accessibility violations - [ ] Every field keyboard reachable with a focus ring - [ ] Submitting with JavaScript disabled works - [ ] An unpublished slug returns 404 ## M4 · Submission and validation Validate on the server against the stored definitions and never lose what someone typed. ### Steps 1. Implement POST /f/:slug with server-side validation against the stored fields Required, email shape, select values drawn from the stored options, length caps. Never trust the client copy of the rules. 2. Preserve every typed value on a validation error Re-render with inline errors and the answers intact; losing a long answer is the worst thing this app can do. 3. Store one answers row per field with the field_id So a renamed or soft-deleted field still renders in past responses. ### Done when - [ ] A valid submission stores one response with one answer per field - [ ] An invalid one re-renders with every typed value intact - [ ] A forged select value outside the stored options is rejected ## M5 · File uploads Safe uploads: streamed, capped, sniffed, stripped. ### Steps 1. Stream multipart to UPLOAD_DIR/<slug>/ under a generated name Cap at MAX_UPLOAD_MB while streaming; check MIME against the decoded header; strip EXIF from images with sharp. ```sh npm install busboy@1 sharp@0.35.3 ``` 2. Never serve UPLOAD_DIR directly; serve via an admin-only route ### Done when - [ ] An oversize file is refused before it is fully read - [ ] A .php renamed to .png is refused - [ ] Two uploads with the same original name coexist - [ ] Uploads are not reachable without admin auth ## M6 · Spam controls Honeypot, minimum fill time, per-IP limits in SQLite. ### Steps 1. Add a CSS-hidden honeypot and a signed minimum-fill timestamp requiring 3 seconds 2. Rate limit 10 submissions per IP per hour in a SQLite table Survives restarts, unlike memory. ### Done when - [ ] A filled honeypot shows thanks and stores nothing - [ ] A sub-3-second submission is refused - [ ] The eleventh in an hour is refused while another IP succeeds ## M7 · Notifications and export Email and webhook that can never make a respondent wait, and a streamed CSV. ### Steps 1. Send email over SMTP_URL and POST the webhook, fire-and-forget with a timeout ```sh npm install nodemailer@6 ``` 2. Build the admin response table and streamed CSV export ### Done when - [ ] A submission still stores and shows thanks with SMTP unreachable and the webhook timing out - [ ] A real email arrives readable on a phone - [ ] A 10,000-row CSV exports without memory growth ## M8 · Deploy Live, backed up including uploads, documented. ### Steps 1. Add /healthz, systemd, Caddy and a backup of data/ Files: `deploy/forms.service`, `Caddyfile` 2. Write the README .env keys, the Caddy snippet, where the database and uploads live, and the honest line that Tally's free tier is generous and the reason to build this is data ownership. Files: `README.md` ### Done when - [ ] A restore brings back both responses and files - [ ] A reader goes from clone to a published form using only the README ## M9 · Operate it like a product (production only) Only for the product-builder path: know when the form server is down, never lose the database, and keep the server patched. ### Steps 1. Add a /healthz endpoint and an external uptime check against it Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices. 2. Write structured request logs and rotate them One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight. 3. Back the SQLite file up off the machine nightly and test a restore SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it. ```sh sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/ ``` 4. Lock the box down Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure. ### Done when - [ ] Stopping the service triggers an uptime alert within a few minutes - [ ] A restore from last night's backup contains yesterday's data - [ ] A port scan from another machine shows only 22, 80 and 443
# Operations · Tally ## Backup tar of data/ nightly off the box. ## Restore Extract, start, check one form and one upload. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime on /healthz; alert on a spike in refused submissions. ## Incident checklist Spam wave: tighten limits, bulk-delete. Compromise: rebuild, restore data/, rotate ADMIN_TOKEN and SMTP credentials. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Every validation path verified with a fixture - [ ] Upload rejection verified with a fake image and an oversize file - [ ] One restore drill including uploads performed and dated - [ ] Notifications verified fire-and-forget with SMTP down ## Launch constraint Do not market omitted Tally capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/forms.db # Required. Where files land, per form slug. UPLOAD_DIR=./data/uploads # Required · secret. openssl rand -base64 32. The only admin credential. ADMIN_TOKEN=base64-from-openssl # Required. Public base URL. SITE_URL=https://forms.yourdomain.com # Optional · secret. SMTP connection string from your provider. SMTP_URL=smtps://user:pass@smtp.fastmail.com:465 # Optional. From address for notifications. NOTIFY_FROM=forms@yourdomain.com # Optional. Per-file cap, enforced while streaming. MAX_UPLOAD_MB=10
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay for low-friction forms, branding removal, and integrations.
xbeautiful editor
xunlimited free usage
xpartial submissions
xcustom domains
xintegrations
xteam workspaces
xanti-spam
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Tally pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | Unlimited forms/submissions/e-signatures; 10 MB/file. Fair-use examples: 50,000 submissions/month, 100 GB uploads/month, 500 GB total storage or 50,000 emails/month. |
| pro | $29/workspace | $24.17/workspace | Unlimited team members, unlimited forms/submissions under fair use and 30-day version history; removes the 10 MB upload cap. |
| business | $89/workspace | $74.17/workspace | Unlimited team members, unlimited forms/submissions under fair use and 90-day version history. |
free tierUnlimited forms, submissions and e-signatures; 10 MB/file. Fair-use review examples begin with 50,000 submissions/month, 100 GB of uploads/month, 500 GB total storage or 50,000 email notifications/month.
billingmonthly + annual (2 months free, about 17%); annual paid upfront and auto-renews; no paid-plan trial
hidden costsConsistent use around 50,000 submissions/month, 100 GB uploads/month, 500 GB total storage or 50,000 emails/month can trigger a custom plan billed quarterly on top of the existing subscription.
verified 2026-08-11 · source ↗
Is Tally free?
The free plan covers unlimited forms and submissions under fair use. Paid is Pro at $29/mo (checked 2026-08-07).
Vibecode Tally
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Tally replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Tally cost?
Tally costs about $29/month (Pro, checked 2026-08-07), which is $348 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Tally?
Honestly: beautiful editor; unlimited free usage; partial submissions; custom domains; integrations; team workspaces; anti-spam. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Tally?
Yes: HeyForm (Tally's conversational cousin, minus the document-like editor) OpnForm (Open-source forms with unlimited respondents; the free self-hosted builder team stops at two) The prompt is for when you want it exactly your way.