Self-hosting FormTo: the complete guide (Docker, HTTPS, backups)

April 5, 2026 · 11 min read

Server room lights — self-hosted FormTo running on a personal VPS

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.com serving 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 action attribute 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:

  1. Edit the Caddyfile at the project root
  2. Replace {$DOMAIN} with :80 (or :8080 for a non-privileged port)
  3. Leave DOMAIN= empty in formto.env
  4. Run docker compose up -d as 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_dump files to an S3 bucket or another server
  • Monitoring. docker compose ps once 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 compose outage 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.

← All posts