Build Tally

YESreplaces $29/mosaves $348/yrback to the verdict

0%0 of 27 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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 weekendthe files for this build are in the project pack

RuntimeNode 22 with Express and better-sqlite3RenderingServer-rendered HTML, no client frameworkUploadsDisk under uploads/<form-slug>/HostingA VPS behind Caddy

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    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. open ↗

    Verify node --version prints v22 or higher

  2. installfree

    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. open ↗

    Verify You can open a folder and run a command in its terminal

  3. installfree

    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. open ↗

    Verify git --version prints a version

  4. decidefree

    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.

  5. API keyfree 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.

  6. have readyfree

    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. open ↗

  7. 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. open ↗

  8. 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. open ↗

  9. installfree

    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. open ↗

    Verify caddy version prints a version on the server

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

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

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
PORTrequired3000Any free port.
DATABASE_PATHrequired./data/forms.dbSQLite file.
UPLOAD_DIRrequired./data/uploadsWhere files land, per form slug.
ADMIN_TOKENsecretrequiredbase64-from-opensslopenssl rand -base64 32. The only admin credential.
SITE_URLrequiredhttps://forms.yourdomain.comPublic base URL.
SMTP_URLsecretoptionalsmtps://user:pass@smtp.fastmail.com:465SMTP connection string from your provider.
NOTIFY_FROMoptionalforms@yourdomain.comFrom address for notifications.
MAX_UPLOAD_MBoptional10Per-file cap, enforced while streaming.

The build, in order

  1. Form definition and admin auth

    An admin you can log into with the token, and forms you can create.

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

      terminal
      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. POST the token; compare with a constant-time function; set an HttpOnly cookie. Never accept the token in a URL.

    done when · tick each as it passes
  2. Field editor

    Every field kind, reorderable, soft-deleted so old responses keep rendering.

    1. Kinds: text, textarea, email, select, checkbox, file. Explicit buttons work on mobile and are testable; drag can come later.

    2. At least one option, no duplicates, trimmed.

    done when · tick each as it passes
  3. Public form rendering

    Accessible, single column, works without JavaScript, unpublished forms 404.

    1. Labels with for attributes, one question per block, single column, mobile-first. Unpublished forms return 404.

    2. prefers-color-scheme with the same custom properties overridden.

    done when · tick each as it passes
  4. Submission and validation

    Validate on the server against the stored definitions and never lose what someone typed.

    1. Required, email shape, select values drawn from the stored options, length caps. Never trust the client copy of the rules.

    2. Re-render with inline errors and the answers intact; losing a long answer is the worst thing this app can do.

    3. So a renamed or soft-deleted field still renders in past responses.

    done when · tick each as it passes
  5. File uploads

    Safe uploads: streamed, capped, sniffed, stripped.

    1. Cap at MAX_UPLOAD_MB while streaming; check MIME against the decoded header; strip EXIF from images with sharp.

      terminal
      npm install busboy@1 sharp@0.35.3
    done when · tick each as it passes
  6. Spam controls

    Honeypot, minimum fill time, per-IP limits in SQLite.

    1. Survives restarts, unlike memory.

    done when · tick each as it passes
  7. Notifications and export

    Email and webhook that can never make a respondent wait, and a streamed CSV.

    1. terminal
      npm install nodemailer@6
    done when · tick each as it passes
  8. Deploy

    Live, backed up including uploads, documented.

    1. Files deploy/forms.serviceCaddyfile

    2. .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 · tick each as it passes
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.