Deploying FormTo on a €5 Hetzner VPS (the complete walkthrough)

April 5, 2026 · 9 min read

Dim server room lights — a tiny Hetzner VPS running self-hosted FormTo

Here's a story I've told a few friends, and I think it's worth writing down.

For €4.51 a month — that's about five US dollars — Hetzner will rent you a virtual machine with 2 vCPUs, 2GB of RAM, 40GB of NVMe disk, and 20TB of bandwidth. It's in Germany or Finland, it's on fast European infrastructure, and it's a better deal than almost any US cloud provider offers at that price.

I'm going to walk you through deploying a full, working FormTo instance on one of those boxes. HTTPS, database, form endpoints, dashboard, the whole thing. Fifteen minutes of work, start to finish, assuming nothing goes sideways. If everything goes sideways, maybe twenty minutes. I've done this enough times that I timed it for this post.

What you'll end up with

  • A running FormTo instance on a Hetzner VPS
  • A domain like forms.yourdomain.com with automatic Let's Encrypt HTTPS
  • Full admin dashboard for creating forms and viewing submissions
  • Backup script running nightly via cron
  • Total monthly cost: €5 (VPS) + ~€1/year (domain, amortised) = ~€5.10/month

That's less than one cloud form-backend subscription, forever, with no submission limits, no vendor lock-in, and full data ownership.

What you need before you start

  • A Hetzner Cloud account (signup takes 3 minutes, no credit check)
  • A domain you control with access to its DNS records
  • An SSH key on your laptop
  • A terminal

If you don't already have an SSH key:

ssh-keygen -t ed25519 -C "your.email@example.com"

Press enter through the prompts. Your public key will be at ~/.ssh/id_ed25519.pub. Copy its contents; you'll paste it into Hetzner in a minute.

Step 1 — Create the Hetzner server

Log into Hetzner Cloud and click "Create project" → name it something boring like "forms-prod" → "Add Server."

Pick the following:

Setting Value
Location Nuremberg (Germany) or Helsinki (Finland) — pick whichever is closest to your users
Image Ubuntu 22.04
Type CX22 (2 vCPU, 4GB RAM, €4.51/mo) — or CPX11 if you want AMD and slightly more CPU
Network Default (public IPv4 is included on all shared plans)
SSH keys Add your key — paste the contents of ~/.ssh/id_ed25519.pub
Name forms-01

Click "Create & Buy now." The server will be ready in about 15 seconds. Hetzner is very fast.

Grab the server's IPv4 address from the dashboard. You'll need it for the next step.

Note on pricing. Hetzner's CX11 was the old €4/month plan. The current entry-level is usually CX22 at around €4.51/month, subject to Hetzner's pricing changes. The exact SKU changes every year or two. Pick the cheapest shared-CPU option — they all work fine for FormTo.

Step 2 — Point your domain at the server

In your DNS provider, create an A record:

Type:  A
Name:  forms
Value: <your-hetzner-server-ip>
TTL:   300

This makes forms.yourdomain.com resolve to the VPS. Check that it propagated:

dig forms.yourdomain.com +short

Should return your server's IP. If it doesn't, wait 2–5 minutes and try again. DNS is usually fast but sometimes sulks.

Do not skip this step or move to the next one before DNS has propagated. Caddy will try to get a Let's Encrypt certificate as soon as FormTo launches, and if the DNS isn't pointing at the server yet, the certificate request fails and Caddy backs off for an hour before retrying. You'll spend that hour thinking something is broken.

Step 3 — SSH in and install Docker

From your laptop:

ssh root@<your-server-ip>

You should get a root shell. Now install Docker:

curl -fsSL https://get.docker.com | sh

This installs Docker Engine and the Compose plugin in one go. Takes about 30 seconds on Hetzner's fast network.

Create a non-root user so you're not running Docker as root forever:

adduser formto
usermod -aG docker,sudo formto
rsync --archive --chown=formto:formto ~/.ssh /home/formto

Log out and back in as the new user:

exit
ssh formto@<your-server-ip>

From now on everything happens as the formto user, not root. This is a small security hygiene step and it costs nothing.

Step 4 — Clone the repo

git clone https://github.com/lumizone/formto
cd formto

Quick look around:

ls

You should see docker-compose.yml, Caddyfile, formto.env.example, and a few directories for the backend, frontend, and database. Everything you need is in this one folder.

Step 5 — Configure environment

Copy the example env file:

cp formto.env.example formto.env

Generate two secure secrets:

openssl rand -base64 32   # for POSTGRES_PASSWORD
openssl rand -hex 32      # for JWT_SECRET

Open formto.env in nano or vim:

nano formto.env

Set at minimum:

DOMAIN=forms.yourdomain.com
POSTGRES_PASSWORD=<the base64 string from openssl>
JWT_SECRET=<the hex string from openssl>

Save and exit. Do not use default or example values. FormTo prints startup warnings if it detects default secrets, and a production instance with default secrets is trivially compromised.

Optional — SMTP

If you want email notifications from the get-go, add SMTP credentials too. Gmail app passwords work; so do Mailgun, Postmark, and Resend. You can also skip this and configure SMTP later in the UI under Account → Notifications.

Step 6 — Launch

The moment of truth:

docker compose up -d

Docker pulls the images, starts Postgres, runs database migrations, starts the backend and frontend, and launches Caddy. Caddy immediately tries to get a Let's Encrypt certificate for your domain.

Watch the logs for the first minute:

docker compose logs -f

You're looking for:

  • Postgres reporting "database system is ready to accept connections"
  • Backend reporting migrations applied and server listening
  • Caddy reporting successful certificate provisioning for your domain
  • No red error messages

Press Ctrl-C to stop tailing — the containers keep running in the background.

Step 7 — First-run setup wizard

Open https://forms.yourdomain.com in your browser. You should see the FormTo first-run wizard.

  • Enter an admin email
  • Pick a strong password (use a password manager)
  • Submit

You're now the first user on your instance. The wizard drops you into the dashboard. Create your first form — give it a name like "Contact," configure the fields you want, set a notification email or Slack webhook, save.

Copy the form endpoint URL. It'll look like:

https://forms.yourdomain.com/f/contact-abc123

Paste it into the <form action="..."> attribute on your website. Submit a test. Your submission appears in the dashboard within seconds.

You now have a fully working, self-hosted form backend. The hard parts are done. The rest of this post is the operational stuff that keeps it running.

Step 8 — Set up backups

I'm repeating myself from the main self-hosting guide, but this is important enough to restate: do this now, not later.

Create ~/formto-backup.sh:

#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/home/formto/backups"
DATE=$(date +%Y-%m-%d)
mkdir -p "$BACKUP_DIR"

cd /home/formto/formto
docker compose exec -T postgres pg_dump -U formto formto | gzip > "$BACKUP_DIR/formto_$DATE.sql.gz"

# Keep 30 days
find "$BACKUP_DIR" -name "formto_*.sql.gz" -mtime +30 -delete

Make it executable:

chmod +x ~/formto-backup.sh

Test it:

~/formto-backup.sh
ls -lh ~/backups/

You should see a .sql.gz file. Add it to cron:

crontab -e

Add:

0 3 * * * /home/formto/formto-backup.sh >> /home/formto/backup.log 2>&1

Backups every night at 3 a.m. server time.

Copy backups off the server. A local backup on the same VPS doesn't save you from the server being lost entirely. Once a week, scp the newest backup to your laptop or to a Backblaze B2 bucket (cheap — €0.06 per 10GB per month). One line of rclone can automate this.

Step 9 — Lock down SSH

Since we're on a fresh VPS with a public IP, the default SSH config is already good (Hetzner disables password auth on new installs when you provide an SSH key). Double-check by editing /etc/ssh/sshd_config as root and verifying:

PasswordAuthentication no
PermitRootLogin no

If you changed anything, sudo systemctl reload ssh. Confirm from a second terminal that you can still log in before disconnecting the first one.

Also consider running sudo ufw enable with ports 22, 80, and 443 allowed — Hetzner's built-in firewall works too if you prefer managing it in the web UI.

What you just built, in numbers

Let me show you what the €5 bought you.

Resource What it is
2 vCPUs Enough to serve hundreds of simultaneous form submissions
4GB RAM Enough for Postgres, backend, frontend, and Caddy to all run comfortably
40GB NVMe Room for millions of form submissions (most are under 2KB stored)
20TB bandwidth More than any small-to-medium site will ever use
EU data center Regulatory simplicity for European traffic
IPv4 + IPv6 Included
Automatic HTTPS Via Caddy + Let's Encrypt
No submission cap Physical limits of the hardware, not a billing line

For context, the submission cap you'd have to buy from a cloud form backend to match this box is "unlimited," which typically isn't sold at small-business prices. A single Hetzner CX22 can comfortably handle more submissions in a month than 99% of small sites will ever receive.

The ongoing maintenance cost

Realistic estimate of what you'll actually do, month to month:

Task Time
Update FormTo to the latest version 5 min/month
Check backups are running 2 min/month
Rare troubleshooting (once per quarter, maybe) 15 min/quarter
Total ~15 min/month on average

Fifteen minutes a month of babysitting, for €5 and full ownership of your form submissions. If you enjoy tinkering with servers at all, that's genuinely nothing. If you don't enjoy it, the cloud version exists for exactly this reason — same software, none of the server work, $9/month.

Things that can go wrong (and what to do)

Caddy can't get a certificate. Usually means DNS hasn't propagated yet. Wait 10 minutes, restart with docker compose restart caddy. If it still fails, check that ports 80 and 443 are open and nothing else is bound to them.

Backend won't start. Check docker compose logs backend. Usually a missing or invalid env var. Fix formto.env and run docker compose up -d again.

Can't log in after creating account. Usually a JWT_SECRET that changed between requests (e.g., you edited the env file after creating the account). Stop containers, confirm the env, restart, recreate the account.

Server feels slow. Check htop and docker stats. If Postgres is eating all the RAM, your CX22 might actually be too small for your workload — but this is rare. The more common cause is log volume filling the disk; check with df -h and prune Docker logs with docker system prune if needed.

Form endpoint returns 502. The frontend and backend aren't talking. docker compose restart fixes this 95% of the time. If not, logs will tell you what's wrong.

Upgrading later

When a new FormTo release comes out:

cd ~/formto
git pull
docker compose up -d --build

Database migrations run automatically on boot. Total downtime: 20–30 seconds. Watch the releases page at github.com/lumizone/formto/releases if you want to know what changed.

The summary

A working self-hosted form backend on fast European infrastructure, with automatic HTTPS, daily backups, and full data ownership, costs €5 a month and fifteen minutes of your attention per month. If you already have a VPS habit, FormTo fits into it naturally. If you don't, this is a reasonable first service to self-host — the stakes are low, the software is small, and the failure modes are well-understood.

If you try it and decide self-hosting isn't for you, the cloud version runs the same code. You can export your submissions from the self-hosted instance, import them into cloud, switch your form's action URL, and move on. Nothing about this is a one-way decision.


Repo: github.com/lumizone/formto. Star it if you enjoyed the walkthrough.

Related: the complete self-hosting guide (same steps with more detail and fewer Hetzner specifics), FormTo Cloud vs self-hosted (which path fits you), and why we open-sourced it.

← All posts