Can I self-host Jotform?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called HeyForm. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $39 a month you stop paying Jotform — $468 a year on the Bronze plan.

Why people pay for Jotform

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Jotform sells a form builder that someone who has never written a line of code can drive on a Tuesday afternoon, plus the boring parts nobody wants to own: the hosting, the uploads bucket, the payment connectors, the e-signature flow and the template library you start from instead of a blank page. The bill is metered on the two things you cannot control, how many forms you keep and how many people answer them, so a form that does well is the one that moves you up a tier.

Jotform plans and list prices
PlanList priceWhat it buys
StarterfreeFree forever: 5 forms, 100 submissions a month, 10,000 monthly form views, 100 MB of upload space, 10 payment submissions and 10 signed documents a month, 100 fields per form.
Bronzethe plan this page prices against$39/mo25 forms, 1,000 submissions a month, 100,000 monthly views, 1 GB of uploads, 100 payment submissions and 100 signed documents a month. $34/mo billed annually, which the page shows as $408 a year.
Silver$49/mo50 forms, 2,500 submissions a month, 1,000,000 monthly views, 10 GB of uploads, 250 payment submissions and 250 signed documents a month. $39/mo billed annually, shown as $468 a year.
Gold$129/mo100 forms, 10,000 submissions a month, 10,000,000 monthly views, 100 GB of uploads, 1,000 payment submissions and 1,000 signed documents a month, and HIPAA features. $99/mo billed annually, shown as $1,188 a year.
Enterprisequote onlyQuote only. This is the row where the caps read unlimited and where more than one person gets an account.

Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium

Replaced by HeyForm

One project, named before the prompt, so you know what you are about to install.

A drag-and-drop form builder on your own hostname, with file uploads, logic and no monthly submission cap.

The closest match to the shape Jotform actually sells: a drag-and-drop builder with the ordinary field types, conditional logic, file uploads, themes and a submissions inbox, on a hostname you own and with no monthly submission meter deciding your bill. Three containers and an evening, which is the cheapest entry on this page. What you give up is the parts Jotform bolts on around the form: payment connectors, e-signature workflows, HIPAA paperwork and the template library.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. HeyFormour pickONE EVENING— setup effort 2 of 4

    A drag-and-drop form builder on your own hostname, with file uploads, logic and no monthly submission cap.

    The closest match to the shape Jotform actually sells: a drag-and-drop builder with the ordinary field types, conditional logic, file uploads, themes and a submissions inbox, on a hostname you own and with no monthly submission meter deciding your bill. Three containers and an evening, which is the cheapest entry on this page. What you give up is the parts Jotform bolts on around the form: payment connectors, e-signature workflows, HIPAA paperwork and the template library.

  2. FormbricksONGOING OPS— setup effort 4 of 4

    Link surveys and in-product feedback on your own domain, with no monthly response cap deciding your bill.

    Pick this instead when the goal is surveys rather than forms: one question at a time, an in-product widget that catches people while they are still using your app, and drop-off and completion analytics as a first-class feature. It answers the Typeform question more precisely than the Jotform one, and it costs a seven-service stack and a weekend to run, so it is second here on install cost rather than on quality.

  3. LimeSurveyONE EVENING— setup effort 2 of 4

    Research-grade questionnaires on a hostname you own, with branching logic, quotas and no monthly response meter.

    The research-grade option, and the only one on this list built for questionnaires that have to survive a methodologist: quotas, randomisation, panel tokens, offline collection, question types nobody else ships. It comes from a different design era than Jotform and looks it, so choose it when the logic in the questionnaire matters more than the impression the form makes.

The swap

You're paying

Jotform

$39/mo · $468/yr

is replaced by

You'd run

HeyForm

ONE EVENING · ~90 min to running · 2048 MB RAM

Jotform Bronze · vendor list price · checked 2026-08-07 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~90 min1–3 hours, through the first backup

The prompt

Two paths to the same HeyForm: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

335 lines · 14,697 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install HeyForm v3.0.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. That hostname becomes `APP_HOMEPAGE_URL`, and
every form link is it plus `/form/` and an id, so changing it later breaks links already in
other people's inboxes.

HeyForm with MongoDB and Valkey needs 2048 MB of RAM available and 10 GB free on /srv. All
three images publish amd64 and arm64. MongoDB 7 also needs the AVX instruction set on x86:
mongod exits at start-up without it, and no variable fixes that. Measure all five:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
grep -c -w avx /proc/cpuinfo || true
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. Stop as well if the architecture is `amd64` and the AVX count is `0`, or
if `dig +short` prints nothing.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/heyform /srv/heyform/backups /srv/heyform/uploads
ls -la /srv/heyform
```

Assert: `ls -la` shows `backups` and `uploads` owned by the login user. There is no database
directory: the mongo image chowns /data/db to its own uid, so both databases live in named
volumes that step 8 dumps. `uploads` is real because it holds the files respondents attach,
the one thing a dump does not contain.

## 3. Secrets

Four secrets: the session key, the form-token key, the MongoDB password and the Valkey
password. Generate all four on the server. Do not print any of them, do not repeat them in
your summary, and do not put them in a log line. Hex, because two travel in connection
strings.

```bash
umask 077
cat > /srv/heyform/.env <<EOF
APP_HOMEPAGE_URL=https://<DOMAIN>
SESSION_KEY=$(openssl rand -hex 32)
FORM_ENCRYPTION_KEY=$(openssl rand -hex 32)
MONGO_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/heyform/.env
umask 022
ls -l /srv/heyform/.env
```

Replace `<DOMAIN>` on the first line with the real hostname first. Assert: mode
`-rw-------`. It does two jobs: Compose fills the `${MONGO_PASSWORD}` and `${REDIS_PASSWORD}`
slots in the next step from it, and the HeyForm container reads it as its environment.
`SESSION_KEY` encrypts the login cookie, `FORM_ENCRYPTION_KEY` the token a live form page
carries, so rotating either signs everyone out and breaks open form pages.

## 4. compose.yml

```bash
cat > /srv/heyform/compose.yml <<'EOF'
# HeyForm · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting ... https://docs.heyform.net/open-source/self-hosting
#   image and port . https://github.com/heyform/heyform/blob/v3.0.0/Dockerfile
#   variables ...... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/environments/index.ts
#   health ......... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/controller/health.controller.ts
#
# Three services: HeyForm, the MongoDB holding every form and answer, and the
# Valkey carrying sessions and the job queue. Upstream's compose names
# percona/percona-server-mongodb:4.4 and eqalpha/keydb; this file pins mongo
# 7.0, because MongoDB 4.4 left support on 2024-02-29 and HeyForm's Mongoose
# 7.8.7 covers server 7.x, and Valkey, because KeyDB ships no multi-arch
# versioned tag and last released in 2023. Both databases are named volumes:
# the mongo image chowns /data/db to its own uid. Digests read on 2026-08-07;
# all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongo:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    restart: unless-stopped
    command: ["mongod", "--bind_ip_all", "--quiet"]
    environment:
      MONGO_INITDB_ROOT_USERNAME: heyform
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
    volumes:
      - heyform-mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand({ping:1}).ok === 1 ? 0 : 1)"]
      interval: 10s
      retries: 30
      start_period: 20s
    # No `ports:` on either database: both stay on the compose network.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    environment:
      VALKEY_PASSWORD: ${REDIS_PASSWORD}
    command: ["sh", "-c", "exec valkey-server --appendonly yes --requirepass \"$$VALKEY_PASSWORD\""]
    volumes:
      - heyform-valkey:/data
    healthcheck:
      test: ["CMD-SHELL", 'valkey-cli -a "$$VALKEY_PASSWORD" --no-auth-warning ping | grep -q PONG']
      interval: 10s
      retries: 30

  heyform:
    image: heyform/community-edition:v3.0.0@sha256:27507032eb39ddb23dcadb4490ad383a104d1a32a6b368ad0f2e78538a187877
    restart: unless-stopped
    env_file: /srv/heyform/.env
    environment:
      # authSource=admin: the credential is the root user mongo makes there.
      MONGO_URI: mongodb://mongo:27017/heyform?authSource=admin
      MONGO_USER: heyform
      REDIS_HOST: valkey
      REDIS_PORT: 6379
      ENABLE_GOOGLE_FONTS: "false"
    volumes:
      - /srv/heyform/uploads:/app/packages/server/static/upload
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8170.
      - "127.0.0.1:8170:9157"
    depends_on:
      mongo:
        condition: service_healthy
      valkey:
        condition: service_healthy

volumes:
  heyform-mongo:
  heyform-valkey:
EOF
cd /srv/heyform && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. HeyForm serves on 9157 inside its container, the port its
own Dockerfile exposes; 8170 is bound to 127.0.0.1, so Caddy is the only route in.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-heyform
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# HeyForm · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.heyform.net/open-source/self-hosting and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_HOMEPAGE_URL in .env, and HeyForm builds its cookie domain and its
# CORS allowlist from that one value, so the two have to match or the dashboard
# signs you straight back out.

<DOMAIN> {
	# No X-Frame-Options on purpose: HeyForm ships an embed library and turns
	# frameguard off itself, so a form is meant to run inside another page.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8170 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. HeyForm sets Express
	# trust-proxy to 1, so it reads the visitor address Caddy forwards.
	reverse_proxy 127.0.0.1:8170
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-heyform, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it.

## 6. Firewall

Two ports open, both Caddy's, and idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, 443/udp
is HTTP/3. 8170 stays closed because it is bound to 127.0.0.1, and neither 27017 nor
6379 has a host port to firewall. Assert: `ufw status verbose` prints `Status: active`, shows
80, 443/tcp and 443/udp, and no rule for the other three.

## 7. Start and verify

MongoDB creates its root user the first time it initialises an empty volume, and HeyForm waits
for both databases to be healthy.

```bash
cd /srv/heyform
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health/ready); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health/ready
curl -sS https://<DOMAIN>/login | grep -o '<title>HeyForm</title>'
```

Assert all three, and print what you received for each. The loop ends printing `200`. The
readiness body contains `"checks":{"mongo":"up","redis":"up"}`, the only line that proves both
databases answered rather than one failing authentication silently. The grep prints
`<title>HeyForm</title>`, which the server writes into every page it renders. If any of the
three misses, stop, run `docker compose logs --tail 40 heyform` and
`docker compose logs --tail 20 mongo`, and name the cause: `"mongo":"down"` points at step 3,
where a `.env` rewritten after the volume existed leaves the old password in the database. A
running container is not success.

The first screen at https://<DOMAIN>/login is a sign-in form with an `Email address` field and
a `create an account` link under the heading. Registration is open to anyone who reaches this
hostname until the next step closes it.

STOP: tell the user to open https://<DOMAIN>/sign-up now, create their account with a real
email address, and confirm once they are signed in on the workspace screen.
Do not continue until they confirm. Tell them HeyForm refuses disposable-address domains, and
that no confirmation mail arrives: there is no mail server and the account works without one.

Once they confirm, close registration and restart:

```bash
cd /srv/heyform
echo 'APP_DISABLE_REGISTRATION=true' >> /srv/heyform/.env
docker compose up -d --force-recreate heyform
sleep 20
curl -sS https://<DOMAIN>/api/config | grep -o '"appDisableRegistration":true'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/sign-up
```

Assert: the grep prints `"appDisableRegistration":true` and the last curl prints `302`, the
redirect a visitor with no workspace invitation now gets. The sign-up mutation refuses too, so
this is not a hidden button. Then have the user reload https://<DOMAIN>/login and confirm the
`create an account` link is gone. All three land before you report success.

## 8. First backup and restore

Two artifacts. The database holds every form, every submission and the account. The config
archive holds what rebuilds the service, plus the uploads a dump misses.

```bash
cd /srv/heyform
docker compose exec -T mongo sh -c 'mongodump --quiet --archive --gzip --db=heyform -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin' > /srv/heyform/backups/heyform-db-$(date +%F).archive.gz
sudo tar -czf /srv/heyform/backups/heyform-config-$(date +%F).tar.gz -C /srv/heyform compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/heyform/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing goes offline:
`mongodump` reads a running database consistently, and the credentials stay inside the
container. A backup on the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/heyform
scp vps:/srv/heyform/backups/* ~/backups/heyform/
```

To restore: untar the config archive into /srv/heyform first, so compose.yml and .env are back
before any container starts: mongo takes its root password from .env the moment it initialises
an empty volume. Then `docker compose down -v`, the one place `-v` belongs, then
`docker compose up -d mongo`, wait for healthy, and feed the archive in:

```bash
gunzip -c ~/backups/heyform/heyform-db-*.archive.gz | docker compose exec -T mongo sh -c 'mongorestore --archive --drop -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin'
```

Then `docker compose up -d`. Tell the user the stakes: every answer anyone sent them is a row
in that dump, and a form whose responses nobody copied off the box dies with the disk.

## 9. Updating later

New versions are listed at https://github.com/heyform/heyform/releases. Take both backups
first, then edit the image line in /srv/heyform/compose.yml to the new tag and digest:

```bash
cd /srv/heyform
docker compose pull
docker compose up -d
docker compose logs --tail 30 heyform
```

HeyForm applies its own schema changes on the way up, so watch that log until it settles, then
re-run step 7's three asserts. Leave the mongo line alone: a database major version is a
separate migration.

## 10. What will probably go wrong

`APP_HOMEPAGE_URL`. I wrote the hostname in without the scheme, the sign-in page rendered
perfectly over https, and I believed the install had worked. Then every login bounced back to
the sign-in screen with nothing in any log. HeyForm builds the cookie domain and the
credentialed-CORS allowlist from that string, so a value the browser does not read as the
origin it is talking to means the session cookie is set and then ignored. If sign-in loops, run
`docker compose exec -T heyform printenv APP_HOMEPAGE_URL` first. It must read `https://` and
the hostname, no trailing slash, no port.

## 11. Out of scope

- Do not configure SMTP. HeyForm creates the account, signs the user in and records
  submissions with no mail server. Mail adds verification, password reset and response
  notifications, and outbound mail from a fresh VPS is a fight for another day.
- Do not set `GOOGLE_LOGIN_CLIENT_ID` or the Apple login variables. Each is an account
  somewhere else and a second failure mode; this install has a working sign-in.
- Do not set the `S3_` variables. Attachments belong in /srv/heyform/uploads, which step 8
  puts in the backup archive.
- Do not set `OPENAI_API_KEY`, `AKISMET_KEY` or the reCAPTCHA keys. Each is a third-party
  subscription, and the builder, the logic and storage work without them.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing HeyForm v3.0.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.

Read this before step 1. `<DOMAIN>` becomes `APP_HOMEPAGE_URL`, and every form you publish is
that hostname plus `/form/` and an id. Change the hostname later and every link you have sent
out stops working, so pick the one you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
grep -c -w avx /proc/cpuinfo || true
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, a
non-zero AVX count, and your server's IP on the last line.

If you do not: an AVX count of `0` on `amd64` is a hard stop, because MongoDB 7 exits during
start-up on a CPU without it and no setting changes that; move to a box with a newer processor.
An empty last line means the A record does not exist yet: add it, wait a minute, run
`dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that does
not resolve and failed attempts count against a rate limit you cannot see. Under 2048 MB of
RAM is the case where the install looks like it worked and then the OOM killer takes MongoDB
out during your first busy hour.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/heyform /srv/heyform/backups /srv/heyform/uploads
ls -la /srv/heyform
```

You should see: `backups` and `uploads`, both owned by you, and nothing else.

If you do not: there is deliberately no database directory here. The mongo image chowns
/data/db to its own uid, so both databases live in named volumes that step 8 dumps rather than
copies. `uploads` is a real directory because it holds the files respondents attach to a
submission, and those are the one thing a database dump does not contain.

## 3. Secrets

Four secrets: the session key, the form-token key, the MongoDB password and the Valkey
password. All four are generated here, on the server, and all four go straight into a file only
you can read. Hex rather than base64, because two of them travel inside connection strings.

```bash
umask 077
cat > /srv/heyform/.env <<EOF
APP_HOMEPAGE_URL=https://<DOMAIN>
SESSION_KEY=$(openssl rand -hex 32)
FORM_ENCRYPTION_KEY=$(openssl rand -hex 32)
MONGO_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/heyform/.env
umask 022
ls -l /srv/heyform/.env
```

Replace `<DOMAIN>` on the first line with your real hostname before you paste.

You should see: mode `-rw-------`, your own username twice, and the path.

Do not paste that file, any of those four values, or any command output containing them into
this chat window. The agent path never sees them; this one hands them to a third party unless
you keep them out.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/heyform/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all four
secrets, which is fine before the databases exist and a problem afterwards: MongoDB keeps the
password it was created with, so a changed `MONGO_PASSWORD` against an existing volume shows up
as `"mongo":"down"` in step 7 rather than as anything about passwords.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/heyform/compose.yml <<'EOF'
# HeyForm · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting ... https://docs.heyform.net/open-source/self-hosting
#   image and port . https://github.com/heyform/heyform/blob/v3.0.0/Dockerfile
#   variables ...... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/environments/index.ts
#   health ......... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/controller/health.controller.ts
#
# Three services: HeyForm, the MongoDB holding every form and answer, and the
# Valkey carrying sessions and the job queue. Upstream's compose names
# percona/percona-server-mongodb:4.4 and eqalpha/keydb; this file pins mongo
# 7.0, because MongoDB 4.4 left support on 2024-02-29 and HeyForm's Mongoose
# 7.8.7 covers server 7.x, and Valkey, because KeyDB ships no multi-arch
# versioned tag and last released in 2023. Both databases are named volumes:
# the mongo image chowns /data/db to its own uid. Digests read on 2026-08-07;
# all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongo:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    restart: unless-stopped
    command: ["mongod", "--bind_ip_all", "--quiet"]
    environment:
      MONGO_INITDB_ROOT_USERNAME: heyform
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
    volumes:
      - heyform-mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand({ping:1}).ok === 1 ? 0 : 1)"]
      interval: 10s
      retries: 30
      start_period: 20s
    # No `ports:` on either database: both stay on the compose network.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    environment:
      VALKEY_PASSWORD: ${REDIS_PASSWORD}
    command: ["sh", "-c", "exec valkey-server --appendonly yes --requirepass \"$$VALKEY_PASSWORD\""]
    volumes:
      - heyform-valkey:/data
    healthcheck:
      test: ["CMD-SHELL", 'valkey-cli -a "$$VALKEY_PASSWORD" --no-auth-warning ping | grep -q PONG']
      interval: 10s
      retries: 30

  heyform:
    image: heyform/community-edition:v3.0.0@sha256:27507032eb39ddb23dcadb4490ad383a104d1a32a6b368ad0f2e78538a187877
    restart: unless-stopped
    env_file: /srv/heyform/.env
    environment:
      # authSource=admin: the credential is the root user mongo makes there.
      MONGO_URI: mongodb://mongo:27017/heyform?authSource=admin
      MONGO_USER: heyform
      REDIS_HOST: valkey
      REDIS_PORT: 6379
      ENABLE_GOOGLE_FONTS: "false"
    volumes:
      - /srv/heyform/uploads:/app/packages/server/static/upload
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8170.
      - "127.0.0.1:8170:9157"
    depends_on:
      mongo:
        condition: service_healthy
      valkey:
        condition: service_healthy

volumes:
  heyform-mongo:
  heyform-valkey:
EOF
cd /srv/heyform && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/heyform/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/heyform/compose.yml` and paste again in one go. A warning about `MONGO_PASSWORD`
not being set means Compose is not reading the `.env` next to the compose file, which happens
if you ran the command from a different directory than /srv/heyform.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-heyform
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# HeyForm · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.heyform.net/open-source/self-hosting and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_HOMEPAGE_URL in .env, and HeyForm builds its cookie domain and its
# CORS allowlist from that one value, so the two have to match or the dashboard
# signs you straight back out.

<DOMAIN> {
	# No X-Frame-Options on purpose: HeyForm ships an embed library and turns
	# frameguard off itself, so a form is meant to run inside another page.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8170 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. HeyForm sets Express
	# trust-proxy to 1, so it reads the visitor address Caddy forwards.
	reverse_proxy 127.0.0.1:8170
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-heyform /etc/caddy/Caddyfile`, reload,
and paste again. The hostname in this block and the hostname in `APP_HOMEPAGE_URL` have to be
the same string: HeyForm builds its cookie domain and its credentialed-CORS allowlist from that
one value, and a mismatch gives you a sign-in page that works and a session that never sticks.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8170`, `27017` or `6379`.

If you do not: delete anything for those three with `sudo ufw delete allow 8170`. 8170 is bound
to 127.0.0.1 by the compose file, and neither database publishes a host port at all, so no
firewall rule could apply to them. 80/tcp is there to redirect to HTTPS and to answer the ACME
challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

MongoDB creates its root user the first time it initialises an empty volume, and HeyForm waits
for both databases to report healthy before it starts, so the first boot is slower than the
ones after it.

```bash
cd /srv/heyform
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health/ready); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health/ready
curl -sS https://<DOMAIN>/login | grep -o '<title>HeyForm</title>'
```

You should see, in order: the loop reaching `200`, a JSON object containing
`"checks":{"mongo":"up","redis":"up"}`, then `<title>HeyForm</title>`.

If you do not: the readiness body is the one worth reading. It reports the two databases
separately, so `"mongo":"down"` with `"redis":"up"` is an authentication problem rather than a
container that never started, and it points back at step 3. A `502` from Caddy with healthy
containers means the reverse-proxy line is pointing somewhere other than 8170. If the loop
never reaches `200` at all, run `docker compose logs --tail 40 heyform` and
`docker compose logs --tail 20 mongo` in that order. A running container is not success.

The first screen at https://<DOMAIN>/login is a sign-in form with an `Email address` field and
a `create an account` link under the heading. Registration is open to anyone who reaches your
hostname until the next block closes it, so do this now rather than tomorrow.

Open https://<DOMAIN>/sign-up in a browser and create your account with a real email address.
HeyForm refuses disposable-address domains, and no confirmation mail will arrive, because this
install has no mail server and the account works without one. You land on a screen that asks
you to create a workspace.

Then close registration:

```bash
cd /srv/heyform
echo 'APP_DISABLE_REGISTRATION=true' >> /srv/heyform/.env
docker compose up -d --force-recreate heyform
sleep 20
curl -sS https://<DOMAIN>/api/config | grep -o '"appDisableRegistration":true'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/sign-up
```

You should see: `"appDisableRegistration":true`, then `302`. Reload https://<DOMAIN>/login in
your browser and confirm the `create an account` link is gone.

If you do not: an empty first line means the container did not pick up the new variable, so
check that the line landed in /srv/heyform/.env and run the recreate again. A `200` instead of
`302` means the same thing. This setting is enforced in the sign-up mutation as well as in the
page, so once both asserts pass, a stranger who guesses the URL cannot make an account.

## 8. First backup and restore

Two artifacts. The database holds every form, every submission and your account. The config
archive holds the files that rebuild the service around it, plus the uploads a dump does not
contain.

```bash
cd /srv/heyform
docker compose exec -T mongo sh -c 'mongodump --quiet --archive --gzip --db=heyform -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin' > /srv/heyform/backups/heyform-db-$(date +%F).archive.gz
sudo tar -czf /srv/heyform/backups/heyform-config-$(date +%F).tar.gz -C /srv/heyform compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/heyform/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline, and
neither credential is printed, because the shell that expands them runs inside the container.

If you do not: an `.archive.gz` of about 20 bytes is an empty dump, which means `mongodump`
failed and the shell created the file anyway. Re-run the line without the redirect to read the
error. `Authentication failed` there means the password in .env and the password in the volume
disagree, which is the same problem step 7 reports as `"mongo":"down"`.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/heyform
scp vps:/srv/heyform/backups/* ~/backups/heyform/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/heyform/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is one empty workspace:

```bash
cd /srv/heyform
docker compose down -v
docker compose up -d mongo
sleep 30
gunzip -c /srv/heyform/backups/heyform-db-$(date +%F).archive.gz | docker compose exec -T mongo sh -c 'mongorestore --archive --drop -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin'
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/health/ready
```

You should see: restore lines naming the `heyform` database, then a readiness body with both
checks `up`, and your account still works when you sign in.

If you do not: `Authentication failed` means the fresh volume had not finished initialising, so
wait longer and run the `gunzip` line again. `docker compose down -v` drops the database volume
on purpose, which is the whole point of the drill, and it is also why `-v` belongs on no other
command in this file. Understand the stakes before you skip this: every answer anyone ever
sends you is a row in that dump, and a form whose responses nobody copied off the box dies with
the disk.

## 9. Updating later

New versions are listed at https://github.com/heyform/heyform/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/heyform/compose.yml to the new tag and its
digest.

```bash
cd /srv/heyform
docker compose pull
docker compose up -d
docker compose logs --tail 30 heyform
```

You should see: the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
readiness check from step 7 before you call the update done. Leave the mongo line alone: a
database major version is a separate migration with its own dump and restore.

## 10. What will probably go wrong

`APP_HOMEPAGE_URL`. I wrote the hostname in without the scheme, the sign-in page rendered
perfectly over https, and I believed the install had worked. Then every login bounced back to
the sign-in screen with nothing in any log. HeyForm builds the cookie domain and the
credentialed-CORS allowlist from that string, so a value the browser does not read as the
origin it is talking to means the session cookie is set and then ignored. If sign-in loops, run
`docker compose exec -T heyform printenv APP_HOMEPAGE_URL` first. It must read `https://` and
the hostname, no trailing slash, no port.

## 11. Out of scope

- Do not configure SMTP. HeyForm creates the account, signs you in and records submissions with
  no mail server. Mail adds verification, password reset and response notifications, and
  outbound mail from a fresh VPS is a fight for another day.
- Do not set `GOOGLE_LOGIN_CLIENT_ID` or the Apple login variables. Each is an account
  somewhere else and a second failure mode; this install has a working sign-in.
- Do not set the `S3_` variables. Attachments belong in /srv/heyform/uploads, which step 8
  already puts in the backup archive.
- Do not set `OPENAI_API_KEY`, `AKISMET_KEY` or the reCAPTCHA keys. Each is a third-party
  subscription, and the builder, the logic and storage work without them.

328 lines · 14,978 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install HeyForm v3.0.0, with the MongoDB and Valkey it needs, under ~/selfhost/heyform,
answering at http://localhost:8170.

## 1. Preflight

Say this before step 2 runs; it decides whether the user wants this install at all. Every form
is published at http://localhost:8170/form/ and an id, and that address means "this computer"
wherever it is read, so a link sent to a colleague, or opened on their own phone, reaches
nothing. They get a builder and an inbox they fill in themselves.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
ID and codename print next, for step 2. This needs 2048 MB of RAM available and 10 GB free on
the home disk; all three images publish amd64 and arm64. On macOS and Windows that figure is
the host's, and Docker Desktop takes its share out of it. If RAM is under 2048 MB or disk under
10 GB, print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/heyform/backups ~/selfhost/heyform/uploads
ls -la ~/selfhost/heyform
```

Assert: `ls -la` shows `backups` and `uploads`, owned by the user. There is no database folder:
step 5 keeps both databases in volumes Docker manages, because the mongo image chowns its data
directory to a uid a home bind mount cannot grant on Windows. `uploads` holds what respondents
attach, which the dump misses.

## 4. Secrets

Four secrets: the session key, the form-token key, the MongoDB password and the Valkey
password. Generate all four here, print none, and keep them out of your summary and out of any
log line.

```bash
umask 077
cat > ~/selfhost/heyform/.env <<EOF
SESSION_KEY=$(openssl rand -hex 32)
FORM_ENCRYPTION_KEY=$(openssl rand -hex 32)
MONGO_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/heyform/.env
umask 022
ls -l ~/selfhost/heyform/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these lines run the same everywhere. The
file does two jobs: Compose fills the `${MONGO_PASSWORD}` and `${REDIS_PASSWORD}` slots in step
5 from it, and HeyForm reads it as its environment. `SESSION_KEY` encrypts the login cookie and
`FORM_ENCRYPTION_KEY` the token a live form page carries, so rotating either signs the user
out. On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary
is the user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/heyform/compose.yml <<'EOF'
# HeyForm · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   self-hosting ... https://docs.heyform.net/open-source/self-hosting
#   image and port . https://github.com/heyform/heyform/blob/v3.0.0/Dockerfile
#   variables ...... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/environments/index.ts
#   health ......... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/controller/health.controller.ts
#
# Three services on the computer you are sitting at. Every path is relative to
# ~/selfhost/heyform/, so one file works on macOS, Linux and Windows. mongo 7.0
# and Valkey stand in for upstream's percona-server-mongodb:4.4 and
# eqalpha/keydb: 4.4 left support 2024-02-29, KeyDB ships no multi-arch tag.
# Both databases are named volumes, because the mongo image chowns /data/db to a
# uid Docker Desktop cannot grant on a home bind mount. Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongo:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    restart: unless-stopped
    command: ["mongod", "--bind_ip_all", "--quiet"]
    environment:
      MONGO_INITDB_ROOT_USERNAME: heyform
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
    volumes:
      - heyform-mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand({ping:1}).ok === 1 ? 0 : 1)"]
      interval: 10s
      retries: 30
      start_period: 20s
    # No `ports:` on either database: both stay on the compose network.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    environment:
      VALKEY_PASSWORD: ${REDIS_PASSWORD}
    command: ["sh", "-c", "exec valkey-server --appendonly yes --requirepass \"$$VALKEY_PASSWORD\""]
    volumes:
      - heyform-valkey:/data
    healthcheck:
      test: ["CMD-SHELL", 'valkey-cli -a "$$VALKEY_PASSWORD" --no-auth-warning ping | grep -q PONG']
      interval: 10s
      retries: 30

  heyform:
    image: heyform/community-edition:v3.0.0@sha256:27507032eb39ddb23dcadb4490ad383a104d1a32a6b368ad0f2e78538a187877
    restart: unless-stopped
    env_file: ./.env
    environment:
      APP_HOMEPAGE_URL: http://localhost:8170
      # authSource=admin: the credential is the root user mongo makes there.
      MONGO_URI: mongodb://mongo:27017/heyform?authSource=admin
      MONGO_USER: heyform
      REDIS_HOST: valkey
      REDIS_PORT: 6379
      ENABLE_GOOGLE_FONTS: "false"
    volumes:
      - ./uploads:/app/packages/server/static/upload
    ports:
      # Loopback only: no other device on the wifi can reach 8170.
      - "127.0.0.1:8170:9157"
    depends_on:
      mongo:
        condition: service_healthy
      valkey:
        condition: service_healthy

volumes:
  heyform-mongo:
  heyform-valkey:
EOF
cd ~/selfhost/heyform && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one published port, two named volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule: no hostname to resolve, nothing public to
certify, nothing published past loopback. Browsers treat http://localhost as a secure context,
so pages needing crypto work.

8170 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. For a form builder that is the trade, because nobody else can answer the forms
either. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/heyform/compose.yml
```

Assert: `1`, the published-port line `- "127.0.0.1:8170:9157"`. Neither database publishes a
host port, so neither can appear.

## 7. Start and verify

```bash
cd ~/selfhost/heyform
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8170/health/ready); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8170/health/ready
curl -sS http://localhost:8170/login | grep -o '<title>HeyForm</title>'
```

Assert all three, and print what you received for each. The loop ends printing `200`. The
readiness body contains `"checks":{"mongo":"up","redis":"up"}`, the only line proving both
databases answered rather than one. The grep prints `<title>HeyForm</title>`. If any misses,
stop, run `docker compose logs --tail 40 heyform` and `docker compose logs --tail 20 mongo`,
and name the cause: `"mongo":"down"` points at step 4, where a `.env` rewritten after the
volume existed leaves the old password in the database. `port is already allocated` means
something else holds 8170. A running container is not success.

The first screen at http://localhost:8170/login is a sign-in form with an `Email address` field
and a `create an account` link under the heading.

STOP: tell the user to open http://localhost:8170/sign-up now, create the account with a real
email address, and confirm once they are signed in on the workspace screen.
Do not continue until they confirm. HeyForm refuses disposable-address domains, and no mail
arrives: there is no mail server, and the account works without one.

Once they confirm, close registration, open until now to anything that reaches 8170:

```bash
cd ~/selfhost/heyform
echo 'APP_DISABLE_REGISTRATION=true' >> ~/selfhost/heyform/.env
docker compose up -d --force-recreate heyform
sleep 20
curl -sS http://localhost:8170/api/config | grep -o '"appDisableRegistration":true'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8170/sign-up
```

Assert: the grep prints `"appDisableRegistration":true` and the curl prints `302`. The sign-up
mutation refuses too, so this is not a hidden button. Then have the user reload the login page
and confirm the `create an account` link is gone.

## 8. First backup and restore

Two artifacts: a dump with every form, submission and the account, and a config archive with
what rebuilds the service around it.

```bash
cd ~/selfhost/heyform
docker compose exec -T mongo sh -c 'mongodump --quiet --archive --gzip --db=heyform -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin' > ~/selfhost/heyform/backups/heyform-db-$(date +%F).archive.gz
tar -C ~/selfhost/heyform -czf ~/selfhost/heyform/backups/heyform-config-$(date +%F).tar.gz compose.yml .env uploads
ls -lh ~/selfhost/heyform/backups/
```

Assert: both exist and both are non-empty. Print both sizes. Nothing is stopped, and the
credentials stay in the container.

Both archives sit on the same disk as the data, which is not a backup: on a laptop the disk and
the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or a USB stick, and copy both there with `cp`. Assert: the user confirms both filenames
are listed there, or say plainly this install has no backup.

To restore: untar the config archive into ~/selfhost/heyform first, so compose.yml and .env are
back before any container starts: mongo reads its root password from .env the moment it
initialises an empty volume. Then `docker compose down -v`, the one place `-v` belongs,
`docker compose up -d mongo`, wait 30 seconds for healthy, then:

```bash
gunzip -c ~/selfhost/heyform/backups/heyform-db-*.archive.gz | docker compose exec -T mongo sh -c 'mongorestore --archive --drop -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin'
```

Then `docker compose up -d` and re-run step 7's check. Every answer is in that one dump.

## 9. Updating later

New versions are at https://github.com/heyform/heyform/releases. Take both backups, then edit
the image line in ~/selfhost/heyform/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/heyform
docker compose pull
docker compose up -d
docker compose logs --tail 30 heyform
```

Watch that log until it settles, then re-run step 7's asserts. Leave the mongo line alone.

## 10. What will probably go wrong

I rebooted this machine, opened http://localhost:8170, and got a connection refused that read
like a lost database. It was not: Docker Desktop had not started with the session, so nothing
was listening on 8170. `restart: unless-stopped` acts only once the Docker daemon is up. Turn
on start-at-login, then after a reboot run `cd ~/selfhost/heyform && docker compose up -d`
before concluding anything is broken.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `APP_HOMEPAGE_URL` to this machine's LAN address and do not rebind 8170 to
  0.0.0.0 so a phone can reach it. That puts a sign-up page on every network the user joins.
- Do not configure SMTP, and do not set `GOOGLE_LOGIN_CLIENT_ID`, the Apple login variables,
  `OPENAI_API_KEY`, `AKISMET_KEY`, the reCAPTCHA keys or the `S3_` variables. Each is an
  account somewhere else, and the builder, the logic and storage work without them.
compose.local.ymlthe services, pinned · local layout72 lines

authored from upstream docs, never pasted · 3,008 bytes

# HeyForm · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   self-hosting ... https://docs.heyform.net/open-source/self-hosting
#   image and port . https://github.com/heyform/heyform/blob/v3.0.0/Dockerfile
#   variables ...... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/environments/index.ts
#   health ......... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/controller/health.controller.ts
#
# Three services on the computer you are sitting at. Every path is relative to
# ~/selfhost/heyform/, so one file works on macOS, Linux and Windows. mongo 7.0
# and Valkey stand in for upstream's percona-server-mongodb:4.4 and
# eqalpha/keydb: 4.4 left support 2024-02-29, KeyDB ships no multi-arch tag.
# Both databases are named volumes, because the mongo image chowns /data/db to a
# uid Docker Desktop cannot grant on a home bind mount. Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongo:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    restart: unless-stopped
    command: ["mongod", "--bind_ip_all", "--quiet"]
    environment:
      MONGO_INITDB_ROOT_USERNAME: heyform
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
    volumes:
      - heyform-mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand({ping:1}).ok === 1 ? 0 : 1)"]
      interval: 10s
      retries: 30
      start_period: 20s
    # No `ports:` on either database: both stay on the compose network.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    environment:
      VALKEY_PASSWORD: ${REDIS_PASSWORD}
    command: ["sh", "-c", "exec valkey-server --appendonly yes --requirepass \"$$VALKEY_PASSWORD\""]
    volumes:
      - heyform-valkey:/data
    healthcheck:
      test: ["CMD-SHELL", 'valkey-cli -a "$$VALKEY_PASSWORD" --no-auth-warning ping | grep -q PONG']
      interval: 10s
      retries: 30

  heyform:
    image: heyform/community-edition:v3.0.0@sha256:27507032eb39ddb23dcadb4490ad383a104d1a32a6b368ad0f2e78538a187877
    restart: unless-stopped
    env_file: ./.env
    environment:
      APP_HOMEPAGE_URL: http://localhost:8170
      # authSource=admin: the credential is the root user mongo makes there.
      MONGO_URI: mongodb://mongo:27017/heyform?authSource=admin
      MONGO_USER: heyform
      REDIS_HOST: valkey
      REDIS_PORT: 6379
      ENABLE_GOOGLE_FONTS: "false"
    volumes:
      - ./uploads:/app/packages/server/static/upload
    ports:
      # Loopback only: no other device on the wifi can reach 8170.
      - "127.0.0.1:8170:9157"
    depends_on:
      mongo:
        condition: service_healthy
      valkey:
        condition: service_healthy

volumes:
  heyform-mongo:
  heyform-valkey:

agent-readable mirror: /self-host/jotform.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned73 lines

authored from upstream docs, never pasted · 3,084 bytes

# HeyForm · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting ... https://docs.heyform.net/open-source/self-hosting
#   image and port . https://github.com/heyform/heyform/blob/v3.0.0/Dockerfile
#   variables ...... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/environments/index.ts
#   health ......... https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/controller/health.controller.ts
#
# Three services: HeyForm, the MongoDB holding every form and answer, and the
# Valkey carrying sessions and the job queue. Upstream's compose names
# percona/percona-server-mongodb:4.4 and eqalpha/keydb; this file pins mongo
# 7.0, because MongoDB 4.4 left support on 2024-02-29 and HeyForm's Mongoose
# 7.8.7 covers server 7.x, and Valkey, because KeyDB ships no multi-arch
# versioned tag and last released in 2023. Both databases are named volumes:
# the mongo image chowns /data/db to its own uid. Digests read on 2026-08-07;
# all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mongo:
    image: mongo:7.0.39@sha256:35a5926f71f8b6cb19206bee928c5a85f241a8be99f20c81abe35ae78a73415d
    restart: unless-stopped
    command: ["mongod", "--bind_ip_all", "--quiet"]
    environment:
      MONGO_INITDB_ROOT_USERNAME: heyform
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
    volumes:
      - heyform-mongo:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand({ping:1}).ok === 1 ? 0 : 1)"]
      interval: 10s
      retries: 30
      start_period: 20s
    # No `ports:` on either database: both stay on the compose network.

  valkey:
    image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
    restart: unless-stopped
    environment:
      VALKEY_PASSWORD: ${REDIS_PASSWORD}
    command: ["sh", "-c", "exec valkey-server --appendonly yes --requirepass \"$$VALKEY_PASSWORD\""]
    volumes:
      - heyform-valkey:/data
    healthcheck:
      test: ["CMD-SHELL", 'valkey-cli -a "$$VALKEY_PASSWORD" --no-auth-warning ping | grep -q PONG']
      interval: 10s
      retries: 30

  heyform:
    image: heyform/community-edition:v3.0.0@sha256:27507032eb39ddb23dcadb4490ad383a104d1a32a6b368ad0f2e78538a187877
    restart: unless-stopped
    env_file: /srv/heyform/.env
    environment:
      # authSource=admin: the credential is the root user mongo makes there.
      MONGO_URI: mongodb://mongo:27017/heyform?authSource=admin
      MONGO_USER: heyform
      REDIS_HOST: valkey
      REDIS_PORT: 6379
      ENABLE_GOOGLE_FONTS: "false"
    volumes:
      - /srv/heyform/uploads:/app/packages/server/static/upload
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8170.
      - "127.0.0.1:8170:9157"
    depends_on:
      mongo:
        condition: service_healthy
      valkey:
        condition: service_healthy

volumes:
  heyform-mongo:
  heyform-valkey:
Caddyfilethe hostname and TLS29 lines

authored from upstream docs, never pasted · 1,143 bytes

# HeyForm · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.heyform.net/open-source/self-hosting and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_HOMEPAGE_URL in .env, and HeyForm builds its cookie domain and its
# CORS allowlist from that one value, so the two have to match or the dashboard
# signs you straight back out.

<DOMAIN> {
	# No X-Frame-Options on purpose: HeyForm ships an embed library and turns
	# frameguard off itself, so a form is meant to run inside another page.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8170 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. HeyForm sets Express
	# trust-proxy to 1, so it reads the visitor address Caddy forwards.
	reverse_proxy 127.0.0.1:8170
}
install.shthe same install, no agent168 lines

authored from upstream docs, never pasted · 7,992 bytes

#!/usr/bin/env bash
# HeyForm · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=forms.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.heyform.net/open-source/self-hosting
#   https://github.com/heyform/heyform/blob/v3.0.0/Dockerfile
#   https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/environments/index.ts
#   https://github.com/heyform/heyform/blob/v3.0.0/packages/server/src/controller/health.controller.ts
#   https://github.com/heyform/heyform/blob/v3.0.0/docs/invite-only-registration.md
#
# Four secrets are generated here, on this machine: the session key, the form
# token key, the MongoDB password and the Valkey password. All four go into
# /srv/heyform/.env with mode 600 and none of them is ever printed.
#
# DOMAIN_HOST becomes APP_HOMEPAGE_URL, which every form link is built from, and
# which HeyForm also derives its cookie domain and CORS allowlist from. Choose it
# once: changing it later breaks links you have already sent out.
#
# This script leaves account registration OPEN, because only a human with a
# browser can create the first account. The closing summary gives you the two
# commands that close it. Run them the same hour.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/heyform}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. forms.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; HeyForm plus MongoDB and Valkey wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

# MongoDB 7 exits during start-up on an x86 CPU without AVX, and no setting
# changes that, so it is a preflight failure rather than a runtime surprise.
if [ "$(dpkg --print-architecture)" = "amd64" ] && ! grep -q -w avx /proc/cpuinfo; then
	die "this CPU has no AVX, which MongoDB 7 requires on amd64"
fi

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/uploads"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the four secrets, on the server -----------------------------
#
# Hex for all four: two of them travel inside connection strings and neither
# wants escaping. Read them later with
#   sudo grep -E 'SESSION_KEY|FORM_ENCRYPTION_KEY|MONGO_PASSWORD|REDIS_PASSWORD' /srv/heyform/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		APP_HOMEPAGE_URL=https://${DOMAIN_HOST}
		SESSION_KEY=$(openssl rand -hex 32)
		FORM_ENCRYPTION_KEY=$(openssl rand -hex 32)
		MONGO_PASSWORD=$(openssl rand -hex 32)
		REDIS_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-heyform"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8170, 27017 and 6379 are none of them -----------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8170, 27017 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# MongoDB creates its root user the first time it initialises an empty volume,
# and HeyForm waits for both databases to report healthy before it starts.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health/ready"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health/ready" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/health/ready answered ${code:-nothing}. Check: docker compose logs --tail 40 heyform"

# The readiness body names the two databases separately, which is the only way
# to tell a container that never started from one that failed authentication.
curl -sS "https://${DOMAIN_HOST}/health/ready" | grep -q '"checks":{"mongo":"up","redis":"up"}' \
	|| die "/health/ready answered 200 without both databases up. Check: docker compose logs --tail 40 heyform"

# The application answered through Caddy, not only the health route.
curl -sS "https://${DOMAIN_HOST}/login" | grep -q '<title>HeyForm</title>' \
	|| die "https://${DOMAIN_HOST}/login did not render the HeyForm page. Check the Caddy site block."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T mongo sh -c 'mongodump --quiet --archive --gzip --db=heyform -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin' > "$APP_DIR/backups/heyform-db-${STAMP}.archive.gz"
sudo tar -czf "$APP_DIR/backups/heyform-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/heyform-db-${STAMP}.archive.gz" ] || die "the database dump is empty"

cat <<-DONE

	HeyForm is answering at https://${DOMAIN_HOST}/login

	  1. Registration is OPEN right now, to anyone who finds the hostname.
	     Create your account at https://${DOMAIN_HOST}/sign-up with a real
	     email address (disposable domains are refused, and no confirmation
	     mail arrives because this install has no mail server). Then close
	     registration and check that it closed:

	       cd $APP_DIR
	       echo 'APP_DISABLE_REGISTRATION=true' >> $APP_DIR/.env
	       docker compose up -d --force-recreate heyform
	       sleep 20
	       curl -sS https://${DOMAIN_HOST}/api/config | grep -o '"appDisableRegistration":true'

	     That grep has to print the line, and the sign-in page has to lose its
	     "create an account" link. Do this the same hour, not tomorrow.
	  2. Your four secrets are in $APP_DIR/.env, mode 600. None was printed
	     here. Read them yourself with
	       sudo grep -E 'SESSION_KEY|FORM_ENCRYPTION_KEY' $APP_DIR/.env
	  3. First backup written to $APP_DIR/backups: a MongoDB dump and a config
	     archive that also carries $APP_DIR/uploads and the live Caddy config.
	     They are on the same disk as the data, which is not a backup. Copy
	     them somewhere else tonight:
	       scp vps:$APP_DIR/backups/* ~/backups/heyform/
	  4. No mail server is configured, so response notifications and password
	     reset do not work. That one account is your whole way back in.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Jotform.

  • The submissions are readable rows. HeyForm encrypts the login cookie and the token a live form page carries, and it stores answers as plain fields, together with each respondent's IP address and user agent. A MongoDB dump is therefore the answers themselves: keep it where you would keep a spreadsheet of what people told you, not in a repository.
  • Three services, and the database is not optional. HeyForm needs a MongoDB and a Redis-protocol server to boot at all, and its readiness endpoint refuses until both answer. That is one dump, one archive and two upgrade paths you now own.
  • No mail, by choice. This install configures no SMTP server, which is what makes it an evening rather than a weekend, and it works: the account is created, the sign-in holds, submissions land. What you do not get is the email that tells you a response arrived, the verification message and password reset. That first account is the whole way back in.
  • Upstream's own compose names an end-of-life database. It pins percona-server-mongodb:4.4, which left support in February 2024, and eqalpha/keydb, whose last release was 2023 and which publishes no multi-architecture versioned tag. This install substitutes MongoDB 7.0 and Valkey, both of which the application's drivers speak, and that substitution is ours rather than upstream's.
  • No payments, no e-signatures, no HIPAA paperwork, no template library. Those four are a large part of what the paid tiers are selling on top of the form, and none of them arrives with the container.

Where this came from

“When APP_DISABLE_REGISTRATION=true, public account creation remains disabled, but a visitor with a valid workspace invitation can create an account and join that workspace.”

  • The image this install pins is heyform/community-edition, built and pushed for linux/amd64 and linux/arm64 by upstream's own release workflow and tagged with the release tag. source
  • The server listens on port 9157 by default, email verification is off unless VERIFY_USER_EMAIL is set, and APP_HOMEPAGE_URL is what the cookie domain and the CORS allowlist are derived from, which is why this install needs no SMTP server. source
  • The /health/ready endpoint reports MongoDB and Redis separately and answers 503 until both are up, which is what lets one command tell a container that never started from one that failed authentication. source
  • With APP_DISABLE_REGISTRATION set, /sign-up redirects to /login for a visitor with no workspace invitation cookie, and /api/config reports the flag, so closing registration can be asserted from the command line. source
  • Upstream's documented compose uses MONGO_URI with authSource=admin alongside MONGO_USER and MONGO_PASSWORD when MongoDB runs with credentials, which is the pattern this install follows. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Jotform?

    Not Jotform itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is HeyForm. A drag-and-drop form builder on your own hostname, with file uploads, logic and no monthly submission cap. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Jotform?

    HeyForm. A drag-and-drop form builder on your own hostname, with file uploads, logic and no monthly submission cap. The closest match to the shape Jotform actually sells: a drag-and-drop builder with the ordinary field types, conditional logic, file uploads, themes and a submissions inbox, on a hostname you own and with no monthly submission meter deciding your bill. Three containers and an evening, which is the cheapest entry on this page. What you give up is the parts Jotform bolts on around the form: payment connectors, e-signature workflows, HIPAA paperwork and the template library. Also evaluated and ranked below it: Formbricks, LimeSurvey. The prompt on this page installs HeyForm only. HeyForm is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Jotform?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. HeyForm itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Jotform Bronze, $39/mo — $468 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the HeyForm install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run HeyForm on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same HeyForm on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every form this publishes lives at http://localhost:8170/form/ and an id, which resolves on this computer and nowhere else, so the people you wanted answers from get a connection error instead of question one. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.