Open Source · AGPL-3.0

Self-hosted FormTo

Everything you need to run the open-source build of FormTo on your own server: a five-minute Docker Compose install, domain and HTTPS setup, SMTP, backups, updates, and troubleshooting. No SaaS account needed — all data stays on your infrastructure.

Overview

FormTo is also available as a fully open-source, self-hosted application under the AGPL-3.0 license. It runs on any Linux server with Docker and Docker Compose — no other dependencies. A single git clone, one formto.env file, and docker compose up -d is all you need.

This page is the full installation and operations guide. For the short version, or if you just want to submit forms to api.formto.dev on our hosted SaaS, go back to the main Docs.

Repository: github.com/lumizone/formto — stars, issues, and pull requests welcome.

Why self-host?

  • Data ownership.Every submission lives in your PostgreSQL database. Not ours, not Supabase's, not AWS's.
  • Zero per-month cost. The entire stack fits on a $5 Hetzner VPS. No plan limits, no surcharge for features.
  • Compliance. Keep personal data inside your jurisdiction (GDPR, HIPAA, FINMA, whatever your legal team asks for).
  • Customisable. Node.js + React + SQL migrations — fork, patch, rebuild, own the result.
  • No vendor lock-in. AGPL-3.0 guarantees the code stays open. pg_dump gives you everything in a portable format.

Architecture

Four containers talk to each other on a private Docker network. Only Caddy listens on public ports (80 and 443); everything else is internal.

                  Internet
                      │
                      ▼
              ┌───────────────┐
              │     Caddy     │  ← :80, :443 (auto HTTPS)
              └───────┬───────┘
                      │
        ┌─────────────┴─────────────┐
        ▼                           ▼
  ┌──────────┐                ┌──────────┐
  │ Frontend │                │ Backend  │
  │  React   │                │ Fastify  │
  │  :80     │                │ :3001    │
  └──────────┘                └────┬─────┘
                                   │
                                   ▼
                             ┌──────────┐
                             │PostgreSQL│
                             │  :5432   │
                             └──────────┘

Routing rules inside Caddy:

  • /api/*, /f/*, /health → backend (Fastify on port 3001)
  • Everything else → frontend (React SPA on port 80)

Requirements

Hardware

  • Minimum: 1 vCPU, 1 GB RAM, 10 GB disk
  • Recommended: 2 vCPU, 2 GB RAM, 20 GB SSD — comfortable headroom for PostgreSQL and moderate traffic

Software

  • A Linux distro with a recent kernel (Ubuntu 22.04+, Debian 12+, Alma/Rocky 9+ all work)
  • docker 24+ and docker compose plugin (bundled in Docker Desktop and recent server installs)
  • Open inbound ports 80 and 443
  • A domain name (optional — see Running without a domain)

Installing Docker on a fresh server

# Ubuntu / Debian
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
docker --version
docker compose version

Quick start (5 minutes)

From zero to a running instance:

# 1. Clone the repo
git clone https://github.com/lumizone/formto
cd formto

# 2. Copy and edit the config
cp formto.env.example formto.env
nano formto.env   # set DOMAIN, POSTGRES_PASSWORD, JWT_SECRET

# 3. Generate strong secrets
openssl rand -base64 32   # → POSTGRES_PASSWORD
openssl rand -hex 32      # → JWT_SECRET

# 4. Launch
docker compose up -d

# 5. Watch it come up
docker compose logs -f

Open https://your-domain.comin a browser. Caddy will grab a Let's Encrypt certificate on the first request (takes ~10 seconds). The first-run wizard greets you, you create the admin account, and you're done.

Tip: run docker compose ps to confirm all four containers report Up (healthy). If any are unhealthy, see Troubleshooting.

Domain & DNS setup

You need a domain that points an A record (and optionally an AAAA recordfor IPv6) at your server's public IP. For most forms installs you'll use a dedicated subdomain, e.g. forms.example.com.

Step 1 — find your server's IP

curl -4 ifconfig.me          # IPv4
curl -6 ifconfig.me          # IPv6 (if your server has one)

Step 2 — create the DNS record

In your DNS provider's dashboard (Cloudflare, Route 53, Namecheap, OVH, whatever), add:

TypeNameValueTTL
Aforms203.0.113.45300
AAAAforms2001:db8::45300

Step 3 — verify DNS

dig +short forms.example.com        # should return your IP
dig +short forms.example.com AAAA   # IPv6 (optional)

Wait for propagation (usually under a minute, up to 24 hours depending on provider).

Using Cloudflare? Set the proxy status to DNS only (grey cloud) for the initial certificate issuance. You can turn the orange cloud back on afterwards, but make sure your SSL/TLS mode is Full (strict)— otherwise you'll get redirect loops with Caddy's HTTPS.

Using an apex / root domain

If you want example.com instead of forms.example.com, use @ (or leave the name blank) as the A record name. Some DNS providers support an ALIAS or ANAME record for apex domains when you need to point at a hostname instead of an IP.

HTTPS (automatic)

You do not run certbot. Caddy 2 is built into the stack and handles everything automatically:

  • Issues certificates from Let's Encrypt on first request
  • Renews automatically ~30 days before expiry
  • Redirects plain HTTP to HTTPS
  • Enables HTTP/2 and HTTP/3 (QUIC on UDP 443)

The Caddyfile that ships with the repo reads your domain from the DOMAIN environment variable and routes traffic to the frontend and backend containers:

{$DOMAIN} {
    handle /api/*  { reverse_proxy backend:3001 }
    handle /f/*    { reverse_proxy backend:3001 }
    handle /health { reverse_proxy backend:3001 }
    handle         { reverse_proxy frontend:80  }
}
Firewall: make sure ports 80 (for ACME HTTP-01 challenge and HTTP→HTTPS redirect) and 443(for HTTPS) are open in your cloud provider's firewall and in ufw/firewalld on the server.
# UFW (Ubuntu)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp   # HTTP/3 (optional)
sudo ufw reload

Environment variables

Everything lives in one formto.env file at the repo root. Only three variables are strictly required; the rest have sensible defaults or can be configured later from the UI.

Required

# Your domain — must point to this server's IP via DNS
DOMAIN=forms.example.com

# Strong random password for PostgreSQL
# Generate: openssl rand -base64 32
POSTGRES_PASSWORD=change_me_strong_password

# Secret key for signing JWTs
# Generate: openssl rand -hex 32
JWT_SECRET=change_me_generate_with_openssl_rand_hex_32

Email (SMTP)

SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=you@gmail.com
SMTP_PASS=your-app-password
FROM_EMAIL=FormTo <noreply@forms.example.com>

SMTP can also be configured per account inside the app (Account → Notifications), which is usually cleaner than baking credentials into env files. See SMTP / email.

Uploads and logging

MAX_FILE_SIZE_MB=10
LOG_LEVEL=info   # fatal, error, warn, info, debug, trace

Full reference

VariableDefaultDescription
DOMAINPublic hostname served by Caddy
POSTGRES_PASSWORDDB password (used by postgres + backend)
JWT_SECRETHS256 signing key — must be ≥ 32 bytes
SMTP_HOSTSMTP server hostname
SMTP_PORT587SMTP port (465 for TLS, 587 for STARTTLS)
SMTP_SECUREfalsetrue for implicit TLS on 465
SMTP_USERSMTP username
SMTP_PASSSMTP password or app password
FROM_EMAILDisplay name and address for outgoing mail
MAX_FILE_SIZE_MB10Per-file upload cap
LOG_LEVELinfoPino log level
Never commit formto.envwith real secrets to a public repository. It's already in .gitignore by default — keep it that way.

First-run wizard

The very first time you load the app, the frontend detects an empty database and shows a setup wizard instead of the login screen. You provide:

  • Admin email
  • Full name (shown in notifications and reply-to headers)
  • Password (≥ 10 characters; bcrypt-hashed server-side)

The wizard disappears after the first account is created. From that point on, the root URL shows the normal login page. There's no "sign up" button — self-hosted FormTo is single-user by design. If you need multiple collaborators, create shared accounts or stand up separate instances.

Forgot your password? Shell into PostgreSQL (docker compose exec postgres psql -U formto formto) and DELETE FROM users; — the wizard comes back on the next request.

SMTP / email

FormTo uses nodemailer to send two kinds of email:

  • Submission notifications to you when a form gets a new response
  • Autoresponders and replies from the inbox

You can configure SMTP in one of two places:

Option 1 — global env file

Set SMTP_* variables in formto.env. Used as a fallback if no per-account config is set.

Option 2 — per account (recommended)

Log in → Account → Notifications→ paste SMTP host, port, user, password, and "From" address. Click Send test to verify before saving. Stored encrypted in the database.

Popular providers

ProviderHostPortSecure
Gmail (App Password)smtp.gmail.com587false (STARTTLS)
Resendsmtp.resend.com465true
Mailgunsmtp.mailgun.org587false
Postmarksmtp.postmarkapp.com587false
Amazon SESemail-smtp.eu-west-1.amazonaws.com587false
Gmail users: you need an App Password, not your regular account password. Enable 2FA first, then generate a 16-character App Password for "Mail".

Slack & Telegram

Slack

Create an Incoming Webhook in your Slack workspace, copy the URL (looks like https://hooks.slack.com/services/T0.../B0.../XXX), and paste it into Account → Notificationsor into a specific form's settings. Submissions are formatted as rich Block Kit messages with the form name and up to 10 fields.

Telegram

Talk to @BotFather, run /newbot, and copy the token. Then get your chat ID by messaging your bot and visiting https://api.telegram.org/bot<TOKEN>/getUpdates. Enter token and chat ID in Account → Notifications.

Submissions arrive as MarkdownV2 messages with safe escaping.

Webhooks

Per-form webhooks POST JSON to any URL when a submission is received. Payload format matches the SaaS version — see the Webhooks section in the main docs for the schema and signature verification code.

SSRF protection: FormTo rejects webhook URLs that resolve to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, IPv6 equivalents) and enforces DNS pinning to prevent TOCTOU attacks.

Webhook delivery attempts, response codes, and retry attempts are visible in the dashboard so you can debug failures.

File uploads

The open-source build accepts file uploads via multipart/form-data on form submissions. The default storage adapter is a no-op stub — you must provide your own storage if you want file attachments to actually persist.

Bringing your own storage

Implement dbHelpers.uploadFile() in backend/utils/db.js. It receives a file buffer + metadata and must return a public URL (or signed URL) string. Common targets:

  • S3 / MinIO — use @aws-sdk/client-s3 and upload with a deterministic key like ${form_endpoint}/${uuid}-${filename}
  • Local disk — write to a Docker volume mounted at /data/uploads, expose via Caddy file_server
  • Cloudflare R2 — S3-compatible API, zero egress fees

Raise MAX_FILE_SIZE_MB in your env file if you need larger attachments. Keep in mind that Caddy and Fastify buffer requests — very large files may need additional tuning.

Running without a domain

For local testing or a dev VPS without DNS, you can skip HTTPS entirely and serve over plain HTTP on the server's IP address.

Step 1 — edit the Caddyfile

:80 {
    handle /api/*  { reverse_proxy backend:3001 }
    handle /f/*    { reverse_proxy backend:3001 }
    handle /health { reverse_proxy backend:3001 }
    handle         { reverse_proxy frontend:80  }
}

Step 2 — leave DOMAIN empty

DOMAIN=
POSTGRES_PASSWORD=...
JWT_SECRET=...

Step 3 — open the IP directly

Browse to http://203.0.113.45. Works fine for development, but browsers will flag the site as insecure and password managers may refuse to autofill — use a domain for anything approaching production.

Using your own reverse proxy

Already running nginx, Traefik, or HAProxy as a fleet ingress? Remove the Caddy service from docker-compose.yml and expose the frontend and backend ports directly on the host:

# docker-compose.yml — add ports to frontend and backend
services:
  frontend:
    # ...
    ports:
      - "127.0.0.1:8080:80"
  backend:
    # ...
    ports:
      - "127.0.0.1:3001:3001"

nginx example

server {
    listen 443 ssl http2;
    server_name forms.example.com;

    ssl_certificate     /etc/letsencrypt/live/forms.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/forms.example.com/privkey.pem;

    client_max_body_size 50m;   # match MAX_FILE_SIZE_MB

    location /api/  { proxy_pass http://127.0.0.1:3001; }
    location /f/    { proxy_pass http://127.0.0.1:3001; }
    location /health { proxy_pass http://127.0.0.1:3001; }
    location /      { proxy_pass http://127.0.0.1:8080; }

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

External PostgreSQL

Already running a managed Postgres (RDS, Supabase, Neon, Crunchy Bridge, your own cluster)? Remove the postgres service from docker-compose.yml and point the backend at your external instance via environment variables:

# docker-compose.yml — backend section
backend:
  # ...
  environment:
    DB_HOST: db.example.com
    DB_PORT: 5432
    DB_NAME: formto
    DB_USER: formto
    # POSTGRES_PASSWORD from formto.env

Run the initial schema once by hand:

psql "postgresql://formto:$POSTGRES_PASSWORD@db.example.com:5432/formto" \
  < backend/migrations/001_init.sql
Ensure TLS between the backend and your external database, especially over the public internet. Most managed providers require it by default.

Updating

Pull the latest code and rebuild the images:

cd /path/to/formto
git pull
docker compose up -d --build

Schema migrations in backend/migrations/ apply automatically on boot via PostgreSQL's /docker-entrypoint-initdb.d/hook. You never need to run migrations by hand unless you're using an external database.

Always back up before upgrading (see next section). We avoid destructive migrations as a rule, but shit happens.

Pinning a version

Production deploys should pin to a tagged release rather than tracking main:

git fetch --tags
git checkout v1.4.0
docker compose up -d --build

Backup & restore

Everything worth backing up is in the PostgreSQL volume. A single pg_dump is all you need.

Manual backup

docker compose exec postgres \
  pg_dump -U formto formto \
  > backup_$(date +%F).sql

# Compress it
gzip backup_$(date +%F).sql

Automated nightly backup (cron)

# /etc/cron.d/formto-backup
0 3 * * * root cd /path/to/formto && \
  docker compose exec -T postgres pg_dump -U formto formto | \
  gzip > /var/backups/formto/formto_$(date +\%F).sql.gz && \
  find /var/backups/formto -name "formto_*.sql.gz" -mtime +14 -delete

Restore

# Stop the backend first so nothing writes during restore
docker compose stop backend

# Drop and recreate the database
docker compose exec postgres psql -U formto postgres \
  -c "DROP DATABASE formto;" -c "CREATE DATABASE formto;"

# Load the dump
gunzip -c backup_2026-04-05.sql.gz | \
  docker compose exec -T postgres psql -U formto formto

# Bring the backend back
docker compose start backend

Offsite backups

Pipe your gzipped dump into rclone to push backups to S3, Backblaze B2, Wasabi, or any other object store. A single line in cron is enough.

Logs & monitoring

Tail live logs

docker compose logs -f                # everything
docker compose logs -f backend        # backend only
docker compose logs -f caddy          # certificate issues
docker compose logs --tail 200 backend

Log level

Crank up verbosity when debugging by setting LOG_LEVEL=debug (or trace) in formto.env and restarting:

docker compose up -d backend

Health check

GET /health returns JSON with database status — point uptime monitors (Uptime Kuma, BetterStack, Healthchecks.io) at it:

curl https://forms.example.com/health
# {"status":"healthy","database":"connected","timestamp":"..."}

Metrics

If you need Prometheus metrics, drop node_exporter and postgres_exporter next to the stack. The backend itself doesn't expose a metrics endpoint yet — PRs welcome.

Security hardening

Out of the box, FormTo already ships with:

  • Passwords hashed with bcrypt (cost factor 12)
  • JWT auth (HS256, 7-day TTL) with startup warnings on default secrets
  • 100% parameterized SQL queries via postgres.js tagged templates
  • XSS-safe hosted form pages (server-side escaping)
  • SSRF protection on webhook URLs (DNS pinning + private IP blocklist)
  • Honeypot and rate limiting on every public endpoint
  • Security headers from @fastify/helmet

Recommended additions for production

  • Restrict SSH. Disable password login, use keys, change the default port, limit by source IP where you can.
  • Firewall. Default deny, allow only 22/tcp (from your bastion), 80/tcp, 443/tcp + 443/udp.
  • Automatic security updates. unattended-upgrades on Debian/Ubuntu.
  • Fail2ban for SSH and any exposed login endpoints.
  • Rotate JWT_SECRET periodically — users will need to log in again but existing data is untouched.
  • Offsite backups to a provider in a different region.
  • Monitoring that actually pages you (not just a dashboard you forget to look at).
Report vulnerabilities privately. Open a GitHub security advisory — please don't post exploits on public issue trackers.

Troubleshooting

Caddy can't get a certificate

  • Confirm DNS: dig +short your-domain.com must return your server's IP.
  • Confirm port 80 is open to the public internet (Let's Encrypt uses HTTP-01).
  • Check Caddy logs: docker compose logs caddy | tail -100.
  • Hit the Let's Encrypt rate limit? Use the staging env first (edit Caddyfile, add acme_ca https://acme-staging-v02.api.letsencrypt.org/directory).

Backend container is "unhealthy"

  • docker compose logs backend — look for the actual error.
  • Most common: wrong DB_PASSWORD, missing JWT_SECRET, or database still initialising (wait 10 seconds).

Can't log in after setup

  • Did you create an account? The root URL shows a setup wizard only on the first visit.
  • Browser cached an old JWT? Clear localStorage for the site.

Form submissions don't arrive by email

  • Send a test email from Account → Notifications.
  • Check spam. Add FROM_EMAIL domain to SPF/DKIM if you own it.
  • Gmail: did you actually use an App Password (not your normal password)?

Port 80/443 already in use

  • Another web server is running: sudo lsof -i :80 -i :443.
  • Stop it (sudo systemctl stop nginx) or move FormTo to different ports and terminate TLS with your existing proxy (see Using your own reverse proxy).

Differences from the SaaS version

FeatureSaaS (formto.dev)Self-hosted (OSS)
AuthClerk (magic link, OAuth)Built-in JWT + bcrypt
DatabaseSupabase PostgresYour own Postgres 16
EmailResendAny SMTP (nodemailer)
File storageSupabase StorageBring your own adapter
BillingPolar.shNone — all features enabled
Team membersBusiness plan (up to 25)Single user per instance
CAPTCHATurnstile + reCAPTCHA v3Not shipped (PRs welcome)
Telegram notificationsBuilt-in
UpdatesAutomaticgit pull + rebuild

Contributing

Pull requests are welcome. Workflow:

  1. Fork the repo.
  2. Create a branch: git checkout -b feature/your-thing.
  3. Run the local dev setup (see the repo README for the non-Docker dev flow).
  4. Write tests where behaviour is testable (npm test in frontend/).
  5. Open a PR against main with a clear description and a screenshot if it's UI.

For larger features, open an issue first so we can discuss scope. Everything gets reviewed — no drive-by merges.

License

AGPL-3.0.You're free to use, modify, and self-host FormTo — including inside a company, on commercial projects, behind a paywall, wherever. The only requirement is:

If you modify FormTo and run the modified version as a public network service(SaaS, hosted dashboard, anything users interact with over a network), you must publish your source modifications under the same AGPL-3.0 license.

Using it for internal company forms? No obligation to publish anything. Forking and shipping your own "FormTo but better" SaaS? You need to share your changes.

Full text: LICENSE.

← Back to Documentation · GitHub · Contact