Production
Build once, run one Node process, and keep the database on a persistent disk.
A production deployment is one Node.js process that serves the forms, the API, Studio and SQLite. There are no other services to run, unless you keep responses in Postgres.
npm run build # open-form build
npm start # open-form startopen-form build writes two things to dist/:
dist/client/: the browser app (public form pages, Studio), each form's own theme with its CSS and fonts, andembed.js. The reference themes inthemes/are included only for Studio's read-only Themes pages; each loads on demand, and no form page loads them. A reference that fails to load is left out, with a warning.dist/server/entry.js: every form,open-form.config.ts, React and the schema collector, bundled into one file.open-form.config.tsruns when the server starts, so values it reads fromprocess.envcome from the server's environment and are never copied into the build (exceptprocess.env.NODE_ENV, which the build sets toproduction).
open-form start serves that build. It renders every form once at startup, so a
broken form fails the deploy instead of a respondent.
| Flag | Default |
|---|---|
-p, --port <port> | PORT, else port in open-form.config.ts, else 3000 |
--host <host> | HOST, else 0.0.0.0 |
--out-dir <dir> | dist |
Open Form requires Node.js 22.13 or later.
Where to run it
Responses live in a SQLite file, so the server needs a persistent disk:
- A VPS or any machine you control.
- Docker, with a volume for the data.
- Railway or Fly.io, with a volume attached.
Serverless platforms with an ephemeral filesystem (Vercel and similar) lose the
file between invocations, so local SQLite doesn't work there. Keep responses in
Postgres instead, and deploy the project as a
function (Vercel). With Postgres, open-form start needs
no disk for responses either.
By default the data lives in .open-form/ in the project: the database and a
generated signing secret, which is always written next to the database. Either
mount the volume at .open-form/, or point OPEN_FORM_DB at a file on the
volume (/data/open-form.db). The secret lands on the volume either way.
The database isn't encrypted. Open Form makes it, its -wal / -shm files and
the secret readable only by the user the server runs as, and overwrites deleted
responses, but whoever has the disk, or a snapshot or backup of the volume, can
read every response. Keep snapshots and backups as private as the responses
themselves, and prefer OPEN_FORM_SECRET from your platform's secret settings
over the generated file, so a copy of the volume doesn't carry the secret too.
Environment variables
open-form start, like every open-form command except init, loads .env
from the project folder when there is one. Variables already set in the
environment win, so your platform's settings override the file.
| Variable | Purpose |
|---|---|
PORT | Port to listen on without --port (default: port in open-form.config.ts, else 3000). |
HOST | Address to bind without --host (default 0.0.0.0). |
OPEN_FORM_ADMIN_PASSWORD | Enables Studio and the admin API. At least 16 characters with at least 8 different ones, not one word or pattern repeated; a passphrase of a few words works. |
OPEN_FORM_DATABASE_URL | A Postgres connection string: responses go to Postgres instead of SQLite (default: storage.url in open-form.config.ts). |
OPEN_FORM_DB | SQLite file path (default: storage.path in open-form.config.ts, else .open-form/open-form.db). |
OPEN_FORM_SECRET | Signs timing tokens and Studio sessions, and hashes visitor ids (default: generated into a secret file next to the database). At least 16 characters. Keep it stable and private. |
OPEN_FORM_WEBHOOK_SECRET | Signs webhook deliveries. Base64, optionally prefixed whsec_ as the Standard Webhooks libraries expect. |
OPEN_FORM_TRUST_PROXY | Number of reverse proxies in front of the server (usually 1), or true for one. Lets rate limits and sign-in attempts count each visitor instead of the proxy. |
OPEN_FORM_SECURE_COOKIES | With a trusted proxy, cookies are always marked Secure. 0 marks them Secure only for requests made over HTTPS. |
HTTPS and proxies
Put the server behind HTTPS: a reverse proxy or your platform's TLS (Railway,
Fly.io and Render end TLS for you). Forms and Studio work behind such a proxy
without any setting: a page on https:// may submit to the server even though
the server itself is reached over http://, and cookies are marked Secure
whenever the browser is on HTTPS. The proxy has to pass on the browser's Host
header, as those platforms do; with nginx, add proxy_set_header Host $host;.
Pages served over HTTPS carry Strict-Transport-Security (one year, this host
only), so browsers keep using HTTPS. open-form start itself speaks plain HTTP,
so it learns that a page is on HTTPS only from the proxy's X-Forwarded-Proto,
which it reads only while it trusts the proxy (below): without proxy trust, no
Strict-Transport-Security is sent. A Studio sign-in that reaches the server
over plain HTTP from anywhere but localhost logs a warning once: the password
and session cookie crossed the network unencrypted.
Behind a proxy, still set OPEN_FORM_TRUST_PROXY to the number of proxies.
Without it every request seems to come from the proxy, so all visitors share one
count: one person can use up a form's rate limit for everyone, and ten sign-in
attempts lock everyone out of Studio sign-in for 10 minutes. With it, the server
reads the protocol and host from X-Forwarded-Proto and X-Forwarded-Host,
and the visitor's IP from the X-Forwarded-For entry your outermost proxy
added: the address that connected to it. Entries to its left came from the
client and are ignored. The variable takes true (one proxy), false, or a
number; any other value stops the server from starting.
You can also set trustProxy in open-form.config.ts (true for one proxy, or a
number). It takes precedence over the environment variable, false and 0
included.
Never set OPEN_FORM_TRUST_PROXY or trustProxy higher than the real number
of proxies. The extra entries come from the client, so clients could pick their
own IP and slip past the rate limit. While proxy trust is off, the server warns
once when a request carries X-Forwarded-For. It's off unless one of them
turns it on, and a config trustProxy of false or 0 turns it off even
when the variable is set.
Studio in production
Studio exists in production only when OPEN_FORM_ADMIN_PASSWORD is set or
Studio is public (below). Without either, /studio and the admin API answer
404 and the server only serves public forms and their submission endpoint.
open-form start prints each form's URL and whether Studio is enabled.
For a public demo, set
studio: { public: 'read-only' }:
anyone can then read Studio, responses included, without signing in, and only
the password can change anything.
Studio's pages are sent with X-Robots-Tag: noindex, nofollow, so search
engines leave it out of their results, public demo included. Form pages at
/f/<id> carry no such header.
With a password, open /studio and sign in. The session lasts 7 days and signing
out ends it on the server. Changing the password signs everyone out. A password
shorter than 16 characters, with fewer than 8 different characters, or that only
repeats itself (passwordpassword) stops the server from starting.
Sign-in is limited to 10 attempts per IP (per /64 for IPv6) every 10 minutes.
Behind a proxy, that IP is each visitor's own only while the server trusts the
proxy (trustProxy or OPEN_FORM_TRUST_PROXY, above); otherwise all visitors
share the proxy's attempts.
Production Studio has Preview, Responses, Schema and Settings, and the read-only Themes pages. Schedule changes made there are stored in the database as overrides and apply without a redeploy; see Submissions & protection. Edit mode, Adjust, comments, folder editing and deleting forms are development-only: make those changes in code and deploy again.
Studio's pages can't be framed by other sites, and their Content-Security-Policy
lets only the build's own scripts run there. Public forms can be framed only by
your own origin unless you allow others with embed.origins;
their scripts are left to your forms, which may load what they like.