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.
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_dumpgives 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)
docker24+ anddocker composeplugin (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 versionQuick 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 -fOpen 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.
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:
| Type | Name | Value | TTL |
|---|---|---|---|
| A | forms | 203.0.113.45 | 300 |
| AAAA | forms | 2001:db8::45 | 300 |
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 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 }
}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 reloadEnvironment 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_32Email (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, traceFull reference
| Variable | Default | Description |
|---|---|---|
| DOMAIN | — | Public hostname served by Caddy |
| POSTGRES_PASSWORD | — | DB password (used by postgres + backend) |
| JWT_SECRET | — | HS256 signing key — must be ≥ 32 bytes |
| SMTP_HOST | — | SMTP server hostname |
| SMTP_PORT | 587 | SMTP port (465 for TLS, 587 for STARTTLS) |
| SMTP_SECURE | false | true for implicit TLS on 465 |
| SMTP_USER | — | SMTP username |
| SMTP_PASS | — | SMTP password or app password |
| FROM_EMAIL | — | Display name and address for outgoing mail |
| MAX_FILE_SIZE_MB | 10 | Per-file upload cap |
| LOG_LEVEL | info | Pino log level |
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.
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
| Provider | Host | Port | Secure |
|---|---|---|---|
| Gmail (App Password) | smtp.gmail.com | 587 | false (STARTTLS) |
| Resend | smtp.resend.com | 465 | true |
| Mailgun | smtp.mailgun.org | 587 | false |
| Postmark | smtp.postmarkapp.com | 587 | false |
| Amazon SES | email-smtp.eu-west-1.amazonaws.com | 587 | false |
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.
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-s3and upload with a deterministic key like${form_endpoint}/${uuid}-${filename} - Local disk — write to a Docker volume mounted at
/data/uploads, expose via Caddyfile_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.envRun the initial schema once by hand:
psql "postgresql://formto:$POSTGRES_PASSWORD@db.example.com:5432/formto" \
< backend/migrations/001_init.sqlUpdating
Pull the latest code and rebuild the images:
cd /path/to/formto
git pull
docker compose up -d --buildSchema 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.
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 --buildBackup & 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).sqlAutomated 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 -deleteRestore
# 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 backendOffsite 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 backendLog level
Crank up verbosity when debugging by setting LOG_LEVEL=debug (or trace) in formto.env and restarting:
docker compose up -d backendHealth 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.jstagged 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-upgradeson 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).
Troubleshooting
Caddy can't get a certificate
- Confirm DNS:
dig +short your-domain.commust 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, missingJWT_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
localStoragefor the site.
Form submissions don't arrive by email
- Send a test email from Account → Notifications.
- Check spam. Add
FROM_EMAILdomain 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
| Feature | SaaS (formto.dev) | Self-hosted (OSS) |
|---|---|---|
| Auth | Clerk (magic link, OAuth) | Built-in JWT + bcrypt |
| Database | Supabase Postgres | Your own Postgres 16 |
| Resend | Any SMTP (nodemailer) | |
| File storage | Supabase Storage | Bring your own adapter |
| Billing | Polar.sh | None — all features enabled |
| Team members | Business plan (up to 25) | Single user per instance |
| CAPTCHA | Turnstile + reCAPTCHA v3 | Not shipped (PRs welcome) |
| Telegram notifications | — | Built-in |
| Updates | Automatic | git pull + rebuild |
Contributing
Pull requests are welcome. Workflow:
- Fork the repo.
- Create a branch:
git checkout -b feature/your-thing. - Run the local dev setup (see the repo README for the non-Docker dev flow).
- Write tests where behaviour is testable (
npm testinfrontend/). - Open a PR against
mainwith 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:
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