Self-hosting FormTo: the complete guide (Docker, HTTPS, backups)
April 5, 2026 · 11 min read
FormTo is open source under AGPL-3.0 and genuinely self-hostable. Not "self-hostable" in the source-available sense where you can read the code but the real product is gated behind a license server. The repo at lumizone/formto is the exact same software we run for our cloud customers, minus the specific infrastructure wiring.
This guide walks you through getting it running on your own server, from "I just finished typing my SSH password" to "the contact form on my production site is pointing at my own backend." If you can run docker compose, you can do this.
What you will end up with
- A working FormTo instance on your own server
- A domain like
forms.yourcompany.comserving the admin UI and form endpoints - Automatic HTTPS via Let's Encrypt (no manual certbot)
- Postgres 16 holding all your submissions, on your disk
- A backup script you can run or cron
- Updates via
git pull && docker compose up -d --build
Total setup time: about 15 minutes of actual work. Add another 5–10 for DNS propagation.
Prerequisites
- A server with at least 1GB of RAM (2GB is more comfortable). Any Linux VPS works — Hetzner, DigitalOcean, Vultr, Linode, your homelab box.
- A domain you control, with an A record pointed at your server's IP
- Docker and Docker Compose installed
- SSH access to the server
- About 15 minutes
The only hard dependency on the server is Docker. No Node, no Python, no Postgres installed globally. FormTo runs everything inside containers.
Step 1 — Install Docker (if you don't have it)
On Ubuntu or Debian:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
Log out and back in so the group change takes effect. Verify with:
docker compose version
If you see a version, you're good.
Step 2 — Point your domain at the server
In your DNS provider, create an A record:
Type: A
Name: forms
Value: <your server's IPv4 address>
TTL: 300
This will make forms.yourdomain.com resolve to your server. Give it a few minutes to propagate. You can check with dig forms.yourdomain.com +short from your laptop.
If you don't have a domain handy, FormTo can also run on a bare IP — I'll cover that at the end.
Step 3 — Clone the repo
SSH into your server and clone FormTo:
git clone https://github.com/lumizone/formto
cd formto
This pulls the entire codebase onto your server. You'll recognize the layout if you've ever worked on a Docker Compose project — a docker-compose.yml at the root, a Caddyfile alongside it, and service directories for the frontend, backend, and database.
Step 4 — Create the config
FormTo expects a single formto.env file at the project root. There's an example file to copy:
cp formto.env.example formto.env
Open formto.env in your editor and fill in the three required values:
DOMAIN=forms.yourdomain.com
POSTGRES_PASSWORD=<generate one>
JWT_SECRET=<generate one>
Generate the secrets with the commands in the repo README:
# For POSTGRES_PASSWORD
openssl rand -base64 32
# For JWT_SECRET
openssl rand -hex 32
Do not skip this step. Do not use the placeholder values from the example file. If you leave the defaults, FormTo will log warnings on boot and your instance will be trivially compromised within a week. The warnings exist for a reason.
Optional SMTP config
If you want email notifications without configuring them per-account in the UI later, add SMTP credentials to the same file:
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=you@gmail.com
SMTP_PASS=<your app password>
FROM_EMAIL=FormTo <noreply@forms.yourdomain.com>
Gmail, Mailgun, Postmark, Resend — any transactional SMTP works. You can also skip this entirely and configure SMTP per-account later in the UI. I recommend starting without it; the UI-based config is easier to change later.
Step 5 — Launch
One command:
docker compose up -d
Docker pulls the images, builds anything local, starts Postgres, the backend, the frontend, and Caddy. Caddy immediately requests a Let's Encrypt certificate for your domain and sets up auto-renewal.
Tail the logs to watch it come up:
docker compose logs -f
You'll see Postgres initializing, the backend running its migrations (on a fresh install), Caddy negotiating TLS, and the frontend serving static assets. This takes about 30 seconds the first time.
Press Ctrl-C to stop tailing. The containers keep running in the background.
Step 6 — First-run setup
Open https://forms.yourdomain.com in your browser. You should see a first-run setup wizard — FormTo detects that no user accounts exist yet and prompts you to create the first one.
Pick an email and password, submit, and you're in. The wizard creates your admin account and drops you straight into the dashboard. From here you can:
- Create your first form (give it a slug, configure fields, set notifications)
- Copy the form endpoint URL
- Paste it into the
actionattribute of any HTML form on any site you control
That's the whole installation. Your form backend is live on your own infrastructure.
Wiring up a form
Take the endpoint URL from the dashboard. It looks like:
https://forms.yourdomain.com/f/contact-abc123
On your website, replace whatever form you had before with:
<form method="POST" action="https://forms.yourdomain.com/f/contact-abc123">
<label>
Name
<input type="text" name="name" required>
</label>
<label>
Email
<input type="email" name="email" required>
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<!-- honeypot: FormTo drops submissions where this is filled -->
<input type="text" name="website" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px" aria-hidden="true">
<button type="submit">Send</button>
</form>
Submit from a test account. The submission should appear in your FormTo dashboard within a second or two. Email, Slack, Telegram, and webhook notifications will fire if you configured them.
Backups
This is the step that gets skipped and causes pain later. Do it now while you're focused.
Create a backup script at ~/formto-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/formto"
DATE=$(date +%Y-%m-%d)
mkdir -p "$BACKUP_DIR"
cd ~/formto
docker compose exec -T postgres pg_dump -U formto formto | gzip > "$BACKUP_DIR/formto_$DATE.sql.gz"
# Keep the last 30 days
find "$BACKUP_DIR" -name "formto_*.sql.gz" -mtime +30 -delete
echo "Backup saved to $BACKUP_DIR/formto_$DATE.sql.gz"
Make it executable and run it once manually to confirm it works:
chmod +x ~/formto-backup.sh
sudo ~/formto-backup.sh
Then add it to cron:
sudo crontab -e
Add the line:
0 3 * * * /home/yourusername/formto-backup.sh >> /var/log/formto-backup.log 2>&1
Daily backups at 3 a.m. server time. Thirty days of retention. Logged to a file you can check.
Also copy the backups off the server. A local backup doesn't save you from a disk failure. Use rclone, rsync to another box, or just scp the latest file to your laptop once a week. Pick a method and stick with it.
Restoring a backup
gunzip < formto_2026-04-05.sql.gz | docker compose exec -T postgres psql -U formto formto
Run it when you need it, not for the first time during an emergency.
Updating FormTo
Pull new code and rebuild:
cd ~/formto
git pull
docker compose up -d --build
Database migrations run automatically on backend startup. There are no manual steps. The whole update process takes about 30 seconds of downtime during the restart.
If you want zero-downtime updates, that's a more complex topology (blue/green or rolling updates behind a load balancer) and out of scope for a single-server setup. For most self-hosted installs, "30 seconds at 3 a.m." is fine.
Pinning a version
If you don't want to always run the latest commit, check out a specific tag:
git fetch --tags
git checkout v1.4.2
docker compose up -d --build
Watch the releases page for new versions: github.com/lumizone/formto/releases.
The gotchas nobody writes down
A few things I've watched people trip over:
Caddy needs port 80 and 443 to be open. Let's Encrypt validates your domain by hitting your server on port 80. If you have ufw enabled and haven't opened those ports, Caddy will fail to get a certificate and your site will be unreachable. Check with:
sudo ufw status
And open if needed:
sudo ufw allow 80
sudo ufw allow 443
DNS has to be correct before you start. If you run docker compose up -d before your A record has propagated, Caddy will fail the first TLS negotiation, and it'll back off for about an hour before retrying. Wait for dig forms.yourdomain.com +short to return your server's IP before launching.
The first boot takes longer than you think. Postgres has to initialize the data directory. The backend has to run its first set of migrations. Caddy has to request a certificate. On a small VPS this can take 45–60 seconds end-to-end. Don't panic.
Memory matters more than CPU. FormTo is not CPU-bound for typical workloads — it's memory-bound, mostly because of Postgres and Node's baseline overhead. A 512MB VPS will struggle. 1GB works for small sites. 2GB is comfortable for most. 4GB is more than you'll need unless you're running it for a whole agency.
Don't expose Postgres directly. The default Docker Compose setup keeps Postgres on the internal Docker network, not on a public port. Keep it that way. If you ever need to connect to it externally (for a one-off query or migration), use docker compose exec postgres psql or ssh -L port forwarding.
Rotate your JWT secret once you're in production. Not because the initial one is wrong, but because rotating secrets is a good habit and the first secret always gets leaked into someone's scrollback. Update JWT_SECRET in formto.env, restart, and all existing sessions will be invalidated (users re-login).
Running on a bare IP (no domain)
Not everyone has a domain. If you're running FormTo on your home server, a LAN-only VPS, or for a quick test, you can skip the domain entirely:
- Edit the
Caddyfileat the project root - Replace
{$DOMAIN}with:80(or:8080for a non-privileged port) - Leave
DOMAIN=empty informto.env - Run
docker compose up -das normal
Access the app at http://your-server-ip (or http://your-server-ip:8080). You won't get HTTPS in this mode, so don't use it for anything that sends real user data — the traffic will be unencrypted.
This mode is good for quick evaluation and homelab use. For anything production, get a domain and use the normal HTTPS setup. Domains are cheap.
The things you'll want to add later
Once FormTo is running stably, a few follow-ups worth considering:
- Off-site backups. Copy daily
pg_dumpfiles to an S3 bucket or another server - Monitoring.
docker compose psonce a day is fine for small installs; for anything bigger, Uptime Kuma or Better Stack will ping your form endpoint and alert you if it's down - Log retention. Docker keeps container logs by default; you may want to set up logrotate or forward them to a log aggregator
- Email deliverability. If you're sending a lot of notification emails from your own domain, configure SPF, DKIM, and DMARC. The going-to-spam post has details.
- A second instance for staging. If you're shipping changes to FormTo (customizing spam rules, building plugins), stand up a second copy on a subdomain for testing
None of these are required to have a working form backend. All of them are nice to have once you rely on FormTo for real traffic.
The cost
For context, here's what self-hosted FormTo actually costs in money:
| Item | Cost |
|---|---|
| Hetzner CX11 VPS (2 vCPU, 2GB RAM, EU) | €4.51/month |
| Domain (if you don't have one) | ~€10/year |
| Off-site backup storage (Backblaze B2, 10GB) | ~€0.06/month |
| Total | ~€5/month |
Five euros a month to host your own form backend indefinitely, with no submission limits, no vendor, no third-party DPA, and full ownership of your data. The dedicated Hetzner walkthrough has the specific VPS setup instructions if you want that level of detail.
When to stop self-hosting and use the cloud
I've written this whole guide and I'll still say it: self-hosting is not the right answer for everyone. Some scenarios where the cloud version makes more sense:
- You don't want to be your own sysadmin
- You want sub-100ms submission acknowledgment globally (our edge network handles this; a single VPS does not)
- You want the shared spam reputation layer that only works with aggregate data
- Your team is not technical enough to handle a
docker composeoutage at an inconvenient moment - You want someone else to worry about Postgres backups
If any of those apply, just use the hosted version. The software is the same — you're paying for the operational work, not the features. I wrote a longer comparison of the two if you want the full breakdown.
You did it
Congratulations, you own your form backend. Point every form you have at it, configure notifications, set up backups, and forget about it until next month's update. That's the whole job.
Repo: github.com/lumizone/formto. Star it, fork it, file issues, open PRs. AGPL-3.0, so your self-hosted instance is genuinely yours.
Related reading: Why we open-sourced FormTo, FormTo Cloud vs self-hosted, and the Hetzner VPS tutorial.
More posts