Can I self-host Tally?
YES, IF · ONGOING OPS— setup effort 4 of 4YES, IF — it's called OpnForm. It takes one prompt, a 4096 MB VPS, and about 300 minutes. That is $24 a month you stop paying Tally — $288 a year on the Pro plan.
Why people pay for Tally
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.
Tally inverted the form-builder bill. Forms and submissions are unlimited on the free plan, so nobody pays because a survey did well, and the builder itself types like a Notion page rather than like a form designer. What the money buys is everything around the form: your own branding off it, your own domain on it, teammates in the workspace, the visit and drop-off analytics, and the partial submissions that tell you where people gave up. It is a subscription for polish and for a team, not for capacity.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | Unlimited forms and unlimited submissions within Tally's fair use guidelines, plus payments, signatures, file uploads with a 10 MB per-file cap, conditional logic and calculations, password protection, closing a form on a limit or date, answer piping, email notifications to yourself, 45+ languages, webhooks, and the Google Sheets, Notion, Airtable, Zapier and Make integrations. |
| Prothe plan this page prices against | $24/mo | Removes Tally branding, adds custom (sub)domains, unlimited team members in shared workspaces, partial submissions, advanced customization and custom CSS, tailored and custom-domain email notifications, link-preview control, workspaces and folders, removal of the 10 MB upload cap, form visit and drop-off analytics, 30-day version history, and the Google Analytics and Meta Pixel integrations. The page offers a yearly option labelled 2 months off but prints no annual figure. |
| Business | $74/mo | Everything in Pro, plus automatic deletion of submissions after a set retention period, respondent email verification, and version history extended to 90 days. |
Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: medium
Replaced by OpnForm
One project, named before the prompt, so you know what you are about to install.
A no-code form builder on your own hostname, with unlimited forms, file uploads and every answer in a database you hold.
The closest match to the shape Tally actually sells: a block-style builder a non-developer can drive, unlimited forms and submissions with no meter on either, file uploads, conditional logic, webhooks and an embed script, on a hostname you own. It is the honest first answer here and also the expensive one to run, because the API image is php-fpm rather than a web server, so the shipped stack is seven containers with an nginx ingress in front. Two hard limits to price in before you start: a licence-free self-hosted instance is capped at two users in total, and this install configures no mail, so the response notification Tally sends you for free does not arrive.
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.
- OpnFormour pickONGOING OPS— setup effort 4 of 4
A no-code form builder on your own hostname, with unlimited forms, file uploads and every answer in a database you hold.
The closest match to the shape Tally actually sells: a block-style builder a non-developer can drive, unlimited forms and submissions with no meter on either, file uploads, conditional logic, webhooks and an embed script, on a hostname you own. It is the honest first answer here and also the expensive one to run, because the API image is php-fpm rather than a web server, so the shipped stack is seven containers with an nginx ingress in front. Two hard limits to price in before you start: a licence-free self-hosted instance is capped at two users in total, and this install configures no mail, so the response notification Tally sends you for free does not arrive.
- HeyFormONE 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.
Pick this when the install cost matters more than matching Tally feature for feature. HeyForm is three containers and an evening against OpnForm's seven and a weekend, it has no seat cap of its own, and the drag-and-drop builder, the logic and the submissions inbox all do the job. It holds the top spot on this catalogue's Jotform page, where the classic multi-field form is the whole question. OpnForm ranks first here instead because the Tally question is a builder that feels like writing a document with a generous free core behind it, and OpnForm answers that shape more directly. What you give up either way is the same: no partial submissions, no drop-off analytics.
- 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.
The right answer if what you liked about Tally was the measurement rather than the form. Formbricks is built around surveys that catch people inside a product you already run, with completion and drop-off analytics as a first-class feature rather than a paid add-on, and an in-app widget Tally has no equivalent for. It is the heaviest install on this page and the least like Tally to look at, so choose it when the question is why people stopped answering.
The swap
You'd run
OpnForm
ONGOING OPS · ~300 min to running · 4096 MB RAM
Tally Pro · vendor list price · checked 2026-08-14 · source · confidence: medium
Before you start
- RAM floor
- 4096 MBfloor from upstream docs — not measured by us yet
- Disk
- 20 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
- ~300 min3+ hours, then ongoing, through the first backup
The prompt
Two paths to the same OpnForm: 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.
Where it runs
435 lines · 17,694 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 OpnForm 2.3.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 here. Say why it is final: every form is that hostname plus `/forms/`
and a slug, so changing it breaks links already in other people's inboxes.
Seven containers: the Laravel API, a queue worker and a scheduler on one image, the Nuxt client,
PostgreSQL, Redis and the nginx ingress. 4096 MB available, 20 GB free on /srv, amd64 or
arm64.
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
Under either floor, or with empty `dig` output, print what you got and stop: three 1 GB PHP
processes plus Node and PostgreSQL is OOM territory, and Caddy cannot certify a name that does
not resolve; its failed attempts count against a hidden rate limit.
## 2. Layout
Write the ingress config before anything starts: Docker makes a directory where a missing
bind-mount file should be, and nginx refuses a config that is a folder.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/opnform /srv/opnform/backups /srv/opnform/nginx
cat > /srv/opnform/nginx/default.conf <<'EOF'
# OpnForm · the ingress, authored by caniselfhostit from
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker/nginx.conf
# The map strips /api before PHP sees it, since Laravel's routes are at the
# root; `root` is a path inside the api container and only builds
# SCRIPT_FILENAME, so nothing static is served from this one.
map $request_uri $api_uri {
~^/api(/.*$) $1;
default $request_uri;
}
server {
listen 80;
root /usr/share/nginx/html/public;
client_max_body_size 50m;
location / {
proxy_http_version 1.1;
proxy_pass http://client:3000;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location ~/(api|open|local\/temp|forms\/assets)/ {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass api:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param REQUEST_URI $api_uri;
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
}
}
EOF
ls -la /srv/opnform/nginx
```
Assert: `default.conf` is a file, not a directory. PostgreSQL, Redis and the API storage tree
each chown their data, so all three live in named volumes that step 8
dumps rather than copies.
## 3. Secrets
Five, all generated here, none printed and none in your summary or a log line: the Laravel
application key, the JWT signing key, the Nuxt-to-API shared secret, the
PostgreSQL password and the Redis password. `APP_KEY` is `base64:` plus 32 random bytes
(Laravel's own shape); the rest are hex, because two travel inside connection strings.
```bash
umask 077
cat > /srv/opnform/.env <<EOF
APP_URL=https://<DOMAIN>
FRONT_URL=https://<DOMAIN>
APP_KEY=base64:$(openssl rand -base64 32)
JWT_SECRET=$(openssl rand -hex 32)
FRONT_API_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/opnform/.env
umask 022
ls -l /srv/opnform/.env
```
Put the real hostname on the first two lines before running this. Assert: mode `-rw-------`.
Compose reads this file for the `${...}` slots in step 4 and the three PHP containers read it as
their environment. Tell the user `APP_KEY` is what Laravel encrypts with, rotating it makes that
data unreadable, and they read values themselves with `sudo grep JWT_SECRET /srv/opnform/.env`.
## 4. compose.yml
```bash
cat > /srv/opnform/compose.yml <<'EOF'
# OpnForm · the deterministic fallback. Authored by caniselfhostit from
# https://docs.opnform.com/deployment/docker,
# https://docs.opnform.com/configuration/environment-variables and
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker-compose.yml
#
# Seven services, upstream's own shape. The api image is php-fpm on 9000, so
# the nginx ingress speaks FastCGI to it and proxies the rest to the Nuxt
# client under one origin; the host's Caddy fronts that on 8186. api, worker
# and scheduler are one image under three commands, and the queue is not
# optional: an ordinary submission is dispatched to it, not written during the
# request. Digests read from Docker Hub on 2026-08-14; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-api: &api
image: jhumanj/opnform-api:2.3.0@sha256:4b71e200d420c7cd2f3bbc7d8d9431de922c5edb8e49a7679c2d09c858fa7329
restart: unless-stopped
env_file: /srv/opnform/.env
environment:
APP_ENV: production
APP_DEBUG: "false"
SELF_HOSTED: "true"
LOG_CHANNEL: errorlog
LOG_LEVEL: warning
DB_CONNECTION: pgsql
DB_HOST: db
DB_DATABASE: opnform
DB_USERNAME: opnform
REDIS_HOST: redis
CACHE_DRIVER: redis
QUEUE_CONNECTION: redis
SESSION_DRIVER: redis
LOCAL_FILESYSTEM_VISIBILITY: public
# Mail to the container log, not nowhere. Upstream's setup script refuses
# a production deploy with JWT validation skipped. The bridge range lets
# Laravel read the forwarded visitor address; never "*".
MAIL_MAILER: log
JWT_SKIP_IP_UA_VALIDATION: "false"
TRUSTED_PROXIES: 172.16.0.0/12
OPNFORM_ANONYMOUS_TELEMETRY_DISABLED: "true"
volumes:
- opnform-storage:/usr/share/nginx/html/storage
services:
db:
image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
restart: unless-stopped
environment:
POSTGRES_DB: opnform
POSTGRES_USER: opnform
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- opnform-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opnform -d opnform"]
interval: 10s
retries: 30
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\""]
volumes:
- opnform-redis:/data
healthcheck:
test: ["CMD-SHELL", 'redis-cli -a "$$REDIS_PASSWORD" --no-auth-warning ping | grep -q PONG']
interval: 10s
retries: 30
api:
<<: *api
depends_on:
db: {condition: service_healthy}
redis: {condition: service_healthy}
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
interval: 30s
timeout: 15s
retries: 5
# Long: it migrates before it answers, and the other four wait here.
start_period: 300s
worker:
<<: *api
command: ["php", "artisan", "queue:work"]
depends_on:
api: {condition: service_healthy}
scheduler:
<<: *api
command: ["php", "artisan", "schedule:work"]
depends_on:
api: {condition: service_healthy}
client:
image: jhumanj/opnform-client:2.3.0@sha256:1b46bef02db59525e21c9e403805c50839af8c257883430702f1157c6946c1c8
restart: unless-stopped
environment:
NUXT_PUBLIC_APP_URL: ${APP_URL}
NUXT_PUBLIC_API_BASE: ${APP_URL}/api
# Rendering on the server goes back through the ingress: Node cannot
# speak FastCGI. NUXT_API_SECRET is FRONT_API_SECRET renamed.
NUXT_PRIVATE_API_BASE: http://ingress/api
NUXT_API_SECRET: ${FRONT_API_SECRET}
NUXT_PUBLIC_ENV: production
depends_on:
api: {condition: service_healthy}
ingress:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
volumes:
- /srv/opnform/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api: {condition: service_healthy}
client: {condition: service_started}
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8186.
- "127.0.0.1:8186:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/api/healthcheck || exit 1"]
interval: 30s
retries: 5
start_period: 30s
volumes:
opnform-postgres:
opnform-redis:
opnform-storage:
EOF
cd /srv/opnform && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. A complaint that `/srv/opnform/.env` is missing means step 3 did not run.
## 5. Caddy and TLS
Append the block below 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-opnform
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# OpnForm · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.opnform.com/deployment/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is APP_URL, FRONT_URL and
# NUXT_PUBLIC_APP_URL at once, and every form link is it plus /forms/ and a
# slug, so it is the one value here you cannot change your mind about later.
<DOMAIN> {
# No X-Frame-Options on purpose: OpnForm ships an embed script, so a form
# is meant to run inside somebody else's 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
# Matches the ingress and the api image's own 50M PHP upload ceiling.
request_body {
max_size 50MB
}
# 8186 is the loopback port compose publishes for the nginx ingress: not a
# container port, and not open in the firewall. TRUSTED_PROXIES is what
# lets Laravel believe the X-Forwarded-For and -Proto that Caddy sends.
reverse_proxy 127.0.0.1:8186
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-opnform, reload, and
report what it objected to.
## 6. Firewall
Two ports open, both Caddy's, 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 answers the ACME challenge, 443/tcp is the only way in, 443/udp is HTTP/3; 8186 is on
127.0.0.1, and 5432, 6379, 9000 and 3000 have no host port. Assert: `Status: active`,
those three rules, and nothing mentioning 8186.
## 7. Start and verify
First boot is slow on purpose: the api waits for PostgreSQL, runs every migration and
caches its config, and the other four wait for it to be healthy. Five minutes is normal.
```bash
cd /srv/opnform
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/healthcheck
curl -sS https://<DOMAIN>/api/content/feature-flags | grep -o '"setup_required":true'
curl -sSL https://<DOMAIN>/ | grep -c 'Create your admin account'
docker compose ps
```
Assert all five, printing what you got: the loop ends on `200`; health returns
`{"status":"ok","dependencies":{"database":true,"redis":true}}`; the third prints
`"setup_required":true`; the grep prints at least `1` (no account yet, so every path redirects to setup); `ps` shows seven services and no restart loop. If any misses, stop,
run `docker compose logs --tail 60 api ingress` and name the step to blame: `host not found in
upstream` is the ingress starting before the client, fixed by `docker compose up -d ingress`
again, and a `502` with healthy containers is a reverse-proxy line not on 8186. A running
container is not success.
Say this to the user before they touch that page. Anybody reaching this hostname right now can
fill in that form and own the instance, and there is no second chance: OpnForm refuses public
registration permanently once one account exists.
STOP: tell the user to open https://<DOMAIN>, create their admin account, and confirm once they
are signed in on their workspace. Do not continue until they confirm. No confirmation mail
arrives, this install has no mail server, so have them save the password in a manager first.
Then prove the door shut:
```bash
curl -sS https://<DOMAIN>/api/content/feature-flags | grep -o '"setup_required":false'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register
```
Assert: `"setup_required":false`, then `404`, then `302`. Nothing here configured that: upstream's
own controller refuses registration for anyone without an invitation once any user exists, so all
three flip together. A `200` from `/setup` means the account was not created, so go back to the
STOP. Everyone after this joins by invitation from inside the workspace, and upstream caps a
licence-free instance at two users in total.
## 8. First backup and restore
Three artifacts: the database with every form, submission and account; the storage archive with
the attachments a dump does not contain; the config archive that rebuilds the service around both.
```bash
cd /srv/opnform
docker compose exec -T db pg_dump -U opnform -d opnform | gzip > /srv/opnform/backups/opnform-db-$(date +%F).sql.gz
docker compose exec -T api tar -czf - -C /usr/share/nginx/html storage > /srv/opnform/backups/opnform-storage-$(date +%F).tar.gz
sudo tar -czf /srv/opnform/backups/opnform-config-$(date +%F).tar.gz -C /srv/opnform compose.yml .env nginx -C /etc/caddy Caddyfile
ls -lh /srv/opnform/backups/
```
Assert: all three exist and are non-empty; print the sizes. Nothing stops: `pg_dump` snapshots a
running database consistently. Redis is not backed up (cache and queue, not data), so a
submission still queued when this ran is not in it. A backup on the same disk is not a backup, so
run this from the user's machine:
```bash
mkdir -p ~/backups/opnform
scp vps:/srv/opnform/backups/* ~/backups/opnform/
```
To restore, in this order, because the api migrates the moment it reaches a database: untar the
config archive into /srv/opnform so compose.yml and .env are back first, `docker compose down -v`,
the one place `-v` belongs, `docker compose up -d db redis`, wait for healthy, pipe `gunzip -c` on
the `.sql.gz` into `docker compose exec -T db psql -U opnform -d opnform`, `docker compose up -d`,
wait for step 7's health check, then pipe `gunzip -c` on the storage archive into
`docker compose exec -T api tar -xzf - -C /usr/share/nginx/html`. Say the stakes: every answer
anyone ever sent them is a row in that dump.
## 9. Updating later
Releases: https://github.com/OpnForm/OpnForm/releases. Release tag `v2.3.0` = image tag `2.3.0`,
the digits without the `v`. Take all three backups first, then edit the `jhumanj/opnform-api`
line in the `x-api` block and the `jhumanj/opnform-client` line to the new tag and digest. Both
move together: a client built against a different API is the failure that looks like a broken
login.
```bash
cd /srv/opnform
docker compose pull
docker compose up -d
docker compose logs --tail 40 api
docker compose restart ingress
```
That last line is not optional; step 10 says why. OpnForm migrates on the way up: watch the api
log until it settles, then re-run step 7's health check. Leave postgres and redis alone: a database
major is a separate migration with its own restore.
## 10. What will probably go wrong
The ingress will lie to you. I changed one line in .env, recreated the client the way upstream's
documentation says to, and every page went to `502` while `docker compose ps` showed seven healthy
containers and the api answered its own health check perfectly from inside the network. nginx had
resolved `client:3000` to an address once at start-up and kept it, and the recreated container had
come back on a different one. Nothing says so except the ingress log, which reads `connect()
failed`. Any time you recreate `api` or `client`, run `docker compose restart ingress` straight
after, and read `docker compose logs --tail 20 ingress` before concluding anything else is broken.
## 11. Out of scope
- Do not configure SMTP. Sign-in and submissions work with no mail server, and MAIL_MAILER is
`log` so nothing is swallowed. Mail buys password reset, verification and response notices, and
outbound mail from a fresh VPS is a fight for another day.
- Do not set `NUXT_PUBLIC_ROOT_REDIRECT_URL`. The root showing OpnForm's landing page to strangers
is upstream's default, not a fault; where the bare domain sends them is the user's editorial
call.
- Do not set `OPEN_AI_API_KEY`, the hCaptcha or reCAPTCHA keys, `GOOGLE_CLIENT_ID` or the `AWS_`
variables. Each is an account somewhere else and a second failure mode; everything core works
without them.
- Do not activate a self-hosted Enterprise licence and do not set `CUSTOM_CODE_ENABLE_SELF_HOSTED`.
The first is a purchase the user makes, the second turns user-supplied code loose in a page that
strangers load.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 OpnForm 2.3.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_URL`, `FRONT_URL` and `NUXT_PUBLIC_APP_URL` at
once, and every form you publish is that hostname plus `/forms/` and a slug. Change it 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
dig +short <DOMAIN>
```
You should see: at least `4096` MB available, at least `20` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: this install is seven containers, three of them PHP processes each carrying a 1 GB
memory limit, plus a Node server rendering pages and a PostgreSQL. Under 4096 MB is the case where
the install looks like it worked and then the OOM killer takes something out during your first busy
hour, so move to a larger box rather than trying it. 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.
## 2. Layout
Paste the whole block at once, including the last two lines. The ingress configuration has to exist
before any container starts: Docker creates a directory where a missing bind-mount file should be,
and nginx then refuses to start on a config that is a folder.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/opnform /srv/opnform/backups /srv/opnform/nginx
cat > /srv/opnform/nginx/default.conf <<'EOF'
# OpnForm · the ingress, authored by caniselfhostit from
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker/nginx.conf
# The map strips /api before PHP sees it, since Laravel's routes are at the
# root; `root` is a path inside the api container and only builds
# SCRIPT_FILENAME, so nothing static is served from this one.
map $request_uri $api_uri {
~^/api(/.*$) $1;
default $request_uri;
}
server {
listen 80;
root /usr/share/nginx/html/public;
client_max_body_size 50m;
location / {
proxy_http_version 1.1;
proxy_pass http://client:3000;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location ~/(api|open|local\/temp|forms\/assets)/ {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass api:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param REQUEST_URI $api_uri;
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
}
}
EOF
ls -la /srv/opnform/nginx
```
You should see: `default.conf` listed as a file, owned by you, a little over a kilobyte.
If you do not: a `default.conf` listed with a `d` at the start of its permissions is a directory,
which means a container started before this step. Run `docker compose down` in /srv/opnform,
`sudo rm -rf /srv/opnform/nginx/default.conf`, and paste this block again. There is deliberately no
database or uploads directory here, because PostgreSQL, Redis and the API storage tree each chown
their own data to a uid of their choosing, so all three live in named volumes that step 8 dumps
rather than copies.
## 3. Secrets
Five secrets, all generated here on the server: the Laravel application key, the JWT signing key,
the shared secret between the Nuxt server and the API, the PostgreSQL password and the Redis
password. `APP_KEY` has a shape, `base64:` followed by 32 random bytes in base64, which is what
`php artisan key:generate --show` produces; the other four are hex, because two of them travel
inside connection strings.
```bash
umask 077
cat > /srv/opnform/.env <<EOF
APP_URL=https://<DOMAIN>
FRONT_URL=https://<DOMAIN>
APP_KEY=base64:$(openssl rand -base64 32)
JWT_SECRET=$(openssl rand -hex 32)
FRONT_API_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/opnform/.env
umask 022
ls -l /srv/opnform/.env
```
Replace `<DOMAIN>` on the first two lines 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 five 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 when you
paste the lines separately into different shells. Run `chmod 600 /srv/opnform/.env` and carry on. If
the file already existed from an earlier attempt, this block has now overwritten all five, which is
fine before the containers exist and a problem afterwards: PostgreSQL keeps the password it was
created with, so a changed `DB_PASSWORD` against an existing volume shows up in step 7 as
`"database":false` rather than as anything about passwords.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/opnform/compose.yml <<'EOF'
# OpnForm · the deterministic fallback. Authored by caniselfhostit from
# https://docs.opnform.com/deployment/docker,
# https://docs.opnform.com/configuration/environment-variables and
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker-compose.yml
#
# Seven services, upstream's own shape. The api image is php-fpm on 9000, so
# the nginx ingress speaks FastCGI to it and proxies the rest to the Nuxt
# client under one origin; the host's Caddy fronts that on 8186. api, worker
# and scheduler are one image under three commands, and the queue is not
# optional: an ordinary submission is dispatched to it, not written during the
# request. Digests read from Docker Hub on 2026-08-14; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-api: &api
image: jhumanj/opnform-api:2.3.0@sha256:4b71e200d420c7cd2f3bbc7d8d9431de922c5edb8e49a7679c2d09c858fa7329
restart: unless-stopped
env_file: /srv/opnform/.env
environment:
APP_ENV: production
APP_DEBUG: "false"
SELF_HOSTED: "true"
LOG_CHANNEL: errorlog
LOG_LEVEL: warning
DB_CONNECTION: pgsql
DB_HOST: db
DB_DATABASE: opnform
DB_USERNAME: opnform
REDIS_HOST: redis
CACHE_DRIVER: redis
QUEUE_CONNECTION: redis
SESSION_DRIVER: redis
LOCAL_FILESYSTEM_VISIBILITY: public
# Mail to the container log, not nowhere. Upstream's setup script refuses
# a production deploy with JWT validation skipped. The bridge range lets
# Laravel read the forwarded visitor address; never "*".
MAIL_MAILER: log
JWT_SKIP_IP_UA_VALIDATION: "false"
TRUSTED_PROXIES: 172.16.0.0/12
OPNFORM_ANONYMOUS_TELEMETRY_DISABLED: "true"
volumes:
- opnform-storage:/usr/share/nginx/html/storage
services:
db:
image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
restart: unless-stopped
environment:
POSTGRES_DB: opnform
POSTGRES_USER: opnform
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- opnform-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opnform -d opnform"]
interval: 10s
retries: 30
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\""]
volumes:
- opnform-redis:/data
healthcheck:
test: ["CMD-SHELL", 'redis-cli -a "$$REDIS_PASSWORD" --no-auth-warning ping | grep -q PONG']
interval: 10s
retries: 30
api:
<<: *api
depends_on:
db: {condition: service_healthy}
redis: {condition: service_healthy}
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
interval: 30s
timeout: 15s
retries: 5
# Long: it migrates before it answers, and the other four wait here.
start_period: 300s
worker:
<<: *api
command: ["php", "artisan", "queue:work"]
depends_on:
api: {condition: service_healthy}
scheduler:
<<: *api
command: ["php", "artisan", "schedule:work"]
depends_on:
api: {condition: service_healthy}
client:
image: jhumanj/opnform-client:2.3.0@sha256:1b46bef02db59525e21c9e403805c50839af8c257883430702f1157c6946c1c8
restart: unless-stopped
environment:
NUXT_PUBLIC_APP_URL: ${APP_URL}
NUXT_PUBLIC_API_BASE: ${APP_URL}/api
# Rendering on the server goes back through the ingress: Node cannot
# speak FastCGI. NUXT_API_SECRET is FRONT_API_SECRET renamed.
NUXT_PRIVATE_API_BASE: http://ingress/api
NUXT_API_SECRET: ${FRONT_API_SECRET}
NUXT_PUBLIC_ENV: production
depends_on:
api: {condition: service_healthy}
ingress:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
volumes:
- /srv/opnform/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api: {condition: service_healthy}
client: {condition: service_started}
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8186.
- "127.0.0.1:8186:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/api/healthcheck || exit 1"]
interval: 30s
retries: 5
start_period: 30s
volumes:
opnform-postgres:
opnform-redis:
opnform-storage:
EOF
cd /srv/opnform && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/opnform/.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, so
run `rm /srv/opnform/compose.yml` and paste again in one go. A warning that `DB_PASSWORD` is not set
means Compose is not reading the `.env` next to the compose file, which happens when you run the
command from a directory other than /srv/opnform. The `x-api` block at the top is not an eighth
service: Compose ignores keys beginning `x-`, and the three PHP containers merge it in, which is why
`api`, `worker` and `scheduler` differ only in the command they run.
## 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-opnform
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# OpnForm · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.opnform.com/deployment/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is APP_URL, FRONT_URL and
# NUXT_PUBLIC_APP_URL at once, and every form link is it plus /forms/ and a
# slug, so it is the one value here you cannot change your mind about later.
<DOMAIN> {
# No X-Frame-Options on purpose: OpnForm ships an embed script, so a form
# is meant to run inside somebody else's 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
# Matches the ingress and the api image's own 50M PHP upload ceiling.
request_body {
max_size 50MB
}
# 8186 is the loopback port compose publishes for the nginx ingress: not a
# container port, and not open in the firewall. TRUSTED_PROXIES is what
# lets Laravel believe the X-Forwarded-For and -Proto that Caddy sends.
reverse_proxy 127.0.0.1:8186
}
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-opnform /etc/caddy/Caddyfile`, reload, and
paste again. The hostname in this block and the hostname in `APP_URL` have to be the same string.
OpnForm builds every form link and every redirect from that 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 `8186`.
If you do not: delete anything for 8186 with `sudo ufw delete allow 8186`. That port is bound to
127.0.0.1 by the compose file, and PostgreSQL, Redis, php-fpm and the Nuxt client publish no host
port at all, so no firewall rule could apply to them. 80/tcp is there to answer the ACME challenge
and redirect to HTTPS, 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 further.
## 7. Start and verify
First boot is slow on purpose. The api container waits for PostgreSQL, runs every migration and
then caches its configuration, and the worker, the scheduler, the client and the ingress all wait
for it to report healthy before they start. Five minutes is normal on a cold pull.
```bash
cd /srv/opnform
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/healthcheck
curl -sS https://<DOMAIN>/api/content/feature-flags | grep -o '"setup_required":true'
curl -sSL https://<DOMAIN>/ | grep -c 'Create your admin account'
docker compose ps
```
You should see, in order: the loop counting up and ending on `200`; the JSON body
`{"status":"ok","dependencies":{"database":true,"redis":true}}`; the line
`"setup_required":true`; a number of at least `1`; and seven services listed with no restart loop.
If you do not: the health body is the one worth reading, because it names the two dependencies
separately. `"database":false` with `"redis":true` is an authentication problem rather than a
container that never started, and it points back at step 3. If the loop never reaches `200` at all,
run `docker compose logs --tail 60 api` and `docker compose logs --tail 20 ingress` in that order:
`host not found in upstream` from the ingress means it started before the client container existed,
and `docker compose up -d ingress` again fixes it, while a `502` from Caddy with healthy containers
means the reverse-proxy line is pointing somewhere other than 8186. A running container is not
success.
The first screen at https://<DOMAIN> is the setup page: the heading `OpnForm`, then a name, email
and password form under the line `Create your admin account`. Read this before you open it. Anybody
who reaches your hostname right now can fill that form in and own this instance, and there is no
second chance, because OpnForm refuses public registration permanently once the first account
exists. Do it now rather than tomorrow.
Open https://<DOMAIN> in a browser and create your admin account. No confirmation mail arrives,
because this install has no mail server and the account works without one, so save the password in
a manager before you submit the form. You land on your workspace.
Then prove the door is shut:
```bash
curl -sS https://<DOMAIN>/api/content/feature-flags | grep -o '"setup_required":false'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register
```
You should see: `"setup_required":false`, then `404`, then `302`.
If you do not: an empty first line with a `200` from `/setup` means the account was not created, so
go back and create it. Nothing in this install configured any of that. Upstream's own register
controller refuses anyone without a workspace invitation once a single user exists, the setup page
throws a not-found, and `/register` redirects a stranger to the landing page, which is why all three
flip together. From here everyone else joins by invitation from inside your workspace, and upstream
caps a licence-free self-hosted instance at two users in total, counting pending invitations.
## 8. First backup and restore
Three artifacts. The database holds every form, every submission and your account. The storage
archive holds the files respondents attached, which a database dump does not contain. The config
archive holds what rebuilds the service around both, `.env` included.
```bash
cd /srv/opnform
docker compose exec -T db pg_dump -U opnform -d opnform | gzip > /srv/opnform/backups/opnform-db-$(date +%F).sql.gz
docker compose exec -T api tar -czf - -C /usr/share/nginx/html storage > /srv/opnform/backups/opnform-storage-$(date +%F).tar.gz
sudo tar -czf /srv/opnform/backups/opnform-config-$(date +%F).tar.gz -C /srv/opnform compose.yml .env nginx -C /etc/caddy Caddyfile
ls -lh /srv/opnform/backups/
```
You should see: three files, all of them a few kilobytes on a fresh install. Nothing goes offline,
because `pg_dump` snapshots a running database consistently.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and the
shell created the file anyway. Re-run that line without the redirect to read the error. Redis is
deliberately absent from all three archives: it holds cache and the job queue rather than data, so a
submission still sitting in the queue when you ran this is not in the backup.
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/opnform
scp vps:/srv/opnform/backups/* ~/backups/opnform/
```
You should see: three files copied, and all three listed by `ls -lh ~/backups/opnform/`.
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. Order matters,
because the api container runs migrations the moment it can reach a database:
```bash
cd /srv/opnform
docker compose down -v
docker compose up -d db redis
sleep 30
gunzip -c /srv/opnform/backups/opnform-db-$(date +%F).sql.gz | docker compose exec -T db psql -U opnform -d opnform
docker compose up -d
sleep 120
gunzip -c /srv/opnform/backups/opnform-storage-$(date +%F).tar.gz | docker compose exec -T api tar -xzf - -C /usr/share/nginx/html
curl -sS https://<DOMAIN>/api/healthcheck
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then a health body with both
dependencies `true`, and your account still works when you sign in.
If you do not: `docker compose down -v` drops all three volumes on purpose, which is the whole point
of the drill, and it is also why `-v` belongs on no other command in this file. `psql` refusing to
connect means the database container had not finished initialising, so wait longer and run the
`gunzip` line again. If you had already untarred the config archive over /srv/opnform, that is
correct: `.env` has to be back before anything starts, because PostgreSQL takes its password from it
the moment it initialises an empty volume. Understand the stakes before you skip this: every answer
anyone ever sends you is a row in that dump.
## 9. Updating later
New versions are listed at https://github.com/OpnForm/OpnForm/releases. The release tag is `v2.3.0`
and the image tag is `2.3.0`, the same digits without the `v`. Take all three backup artifacts
first, then edit the `jhumanj/opnform-api` line in the `x-api` block and the `jhumanj/opnform-client`
line in /srv/opnform/compose.yml to the new tag and its digest.
```bash
cd /srv/opnform
docker compose pull
docker compose up -d
docker compose logs --tail 40 api
docker compose restart ingress
```
You should see: the api logging its migrations and settling, then no output from the restart.
If you do not: put the old tag and digest back and run the same four commands. The last line is not
optional, and step 10 explains why. Move the api and the client together: a client built against a
different API is the failure that looks like a broken login. Leave the postgres and redis lines
alone, because a database major version is a separate migration with its own dump and restore.
## 10. What will probably go wrong
The ingress will lie to you. I changed one line in .env, recreated the client the way upstream's
documentation says to, and every page went to `502` while `docker compose ps` showed seven healthy
containers and the api answered its own health check perfectly from inside the network. nginx had
resolved `client:3000` to an address once at start-up and kept it, and the recreated container had
come back on a different one. Nothing says so except the ingress log, which reads `connect()
failed`. Any time you recreate `api` or `client`, run `docker compose restart ingress` straight
after, and read `docker compose logs --tail 20 ingress` before concluding anything else is broken.
## 11. Out of scope
- Do not configure SMTP. Your account is created, the sign-in holds and submissions land with no
mail server, and MAIL_MAILER is `log` so nothing is silently swallowed. Mail buys password reset,
email verification and response notifications, and outbound mail from a fresh VPS is a fight for
another day. That one account is your whole way back in.
- Do not set `NUXT_PUBLIC_ROOT_REDIRECT_URL`. The root of your hostname shows OpnForm's own landing
page to anyone not signed in, which is upstream's default rather than a fault, and where the bare
domain sends a stranger is your editorial call.
- Do not set `OPEN_AI_API_KEY`, the hCaptcha or reCAPTCHA keys, `GOOGLE_CLIENT_ID` or the `AWS_`
variables. Each is an account somewhere else and a second failure mode, and the builder, the
logic, the file uploads and the submissions inbox all work without them.
- Do not activate a self-hosted Enterprise licence and do not set `CUSTOM_CODE_ENABLE_SELF_HOSTED`.
The first is a purchase you make, the second turns user-supplied code loose in a page that
strangers load.450 lines · 20,147 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 OpnForm 2.3.0, with the PostgreSQL, Redis and nginx ingress it needs, under
~/selfhost/opnform, answering at http://localhost:8186.
## 1. Preflight
Say this before step 2 runs, because it decides whether the user wants this install at all. Every
form is published at http://localhost:8186/forms/ and a slug, and that address means "this
computer" wherever it is read, so a link sent to a colleague, or opened on the user's own phone,
reaches nothing. What they get is 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. Seven containers with three PHP processes want 4096 MB
available and 20 GB free on the home disk; all five images publish amd64 and arm64. On macOS and
Windows, Docker Desktop takes its allocation out of that figure, so check its resource slider
reads 4 GB or more. Under either floor, print both numbers 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
Write the ingress config before anything starts: Docker makes a directory where a missing
bind-mount file should be, and nginx refuses a config that is a folder.
```bash
mkdir -p ~/selfhost/opnform/nginx ~/selfhost/opnform/backups
cat > ~/selfhost/opnform/nginx/default.conf <<'EOF'
# OpnForm · the ingress, authored by caniselfhostit from
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker/nginx.conf
# The map strips /api before PHP sees it, since Laravel's routes are at the
# root; `root` is a path inside the api container and only builds
# SCRIPT_FILENAME, so nothing static is served from this one.
map $request_uri $api_uri {
~^/api(/.*$) $1;
default $request_uri;
}
server {
listen 80;
root /usr/share/nginx/html/public;
client_max_body_size 50m;
location / {
proxy_http_version 1.1;
proxy_pass http://client:3000;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location ~/(api|open|local\/temp|forms\/assets)/ {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass api:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param REQUEST_URI $api_uri;
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
}
}
EOF
ls -la ~/selfhost/opnform ~/selfhost/opnform/nginx
```
Assert: `nginx` and `backups` are listed, and `default.conf` is a file rather than a directory.
PostgreSQL, Redis and the API storage tree each chown their data to a uid of their own, so step 5
keeps all three in named volumes Docker manages.
## 4. Secrets
Five, all generated here, none printed and none in your summary or a log line: the Laravel
application key, the JWT signing key, the Nuxt-to-API shared secret, the
PostgreSQL password and the Redis password. `APP_KEY` is `base64:` plus 32 random bytes
(Laravel's own shape); the rest are hex, because two travel inside connection strings. Git Bash
ships openssl, so these lines run the same everywhere.
```bash
umask 077
cat > ~/selfhost/opnform/.env <<EOF
APP_KEY=base64:$(openssl rand -base64 32)
JWT_SECRET=$(openssl rand -hex 32)
FRONT_API_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/opnform/.env
umask 022
ls -l ~/selfhost/opnform/.env
```
Assert: mode `-rw-------`. Compose reads this file for the `${...}` slots in step 5 and the three
PHP containers read it as their environment. `APP_KEY` is what Laravel encrypts with, so rotating
it makes that data unreadable. On Windows those mode bits are advisory; the real boundary is the
user's own Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/opnform/compose.yml <<'EOF'
# OpnForm · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://docs.opnform.com/deployment/docker,
# https://docs.opnform.com/configuration/environment-variables and
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker-compose.yml
#
# Seven services on the computer you are sitting at, upstream's own shape. The
# api image is php-fpm on 9000, so the nginx ingress marries FastCGI to the
# Nuxt client under one origin and is the only service with a published port.
# api, worker and scheduler are one image under three commands, and the queue
# is where an ordinary submission is written. The ingress config is a relative
# bind mount so you can open it in Finder or Explorer; the data lives in named
# volumes, because each of those images chowns its own directory to a uid a
# home bind mount cannot grant on Windows. Digests read 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-api: &api
image: jhumanj/opnform-api:2.3.0@sha256:4b71e200d420c7cd2f3bbc7d8d9431de922c5edb8e49a7679c2d09c858fa7329
restart: unless-stopped
env_file: ./.env
environment:
APP_ENV: production
APP_DEBUG: "false"
SELF_HOSTED: "true"
APP_URL: http://localhost:8186
FRONT_URL: http://localhost:8186
LOG_CHANNEL: errorlog
LOG_LEVEL: warning
DB_CONNECTION: pgsql
DB_HOST: db
DB_DATABASE: opnform
DB_USERNAME: opnform
REDIS_HOST: redis
CACHE_DRIVER: redis
QUEUE_CONNECTION: redis
SESSION_DRIVER: redis
LOCAL_FILESYSTEM_VISIBILITY: public
# No SMTP, so mail lands in the container log rather than nowhere.
MAIL_MAILER: log
# Upstream's setup script refuses a production deploy with this true.
JWT_SKIP_IP_UA_VALIDATION: "false"
# Docker's bridge range, so Laravel reads the address the ingress
# forwarded rather than the ingress container's own. Never "*".
TRUSTED_PROXIES: 172.16.0.0/12
OPNFORM_ANONYMOUS_TELEMETRY_DISABLED: "true"
volumes:
- opnform-storage:/usr/share/nginx/html/storage
services:
db:
image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
restart: unless-stopped
environment:
POSTGRES_DB: opnform
POSTGRES_USER: opnform
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- opnform-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opnform -d opnform"]
interval: 10s
retries: 30
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\""]
volumes:
- opnform-redis:/data
healthcheck:
test: ["CMD-SHELL", 'redis-cli -a "$$REDIS_PASSWORD" --no-auth-warning ping | grep -q PONG']
interval: 10s
retries: 30
api:
<<: *api
depends_on:
db: {condition: service_healthy}
redis: {condition: service_healthy}
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
interval: 30s
timeout: 15s
retries: 5
# Long: it migrates before it answers, and the other four wait here.
start_period: 300s
worker:
<<: *api
command: ["php", "artisan", "queue:work"]
depends_on:
api: {condition: service_healthy}
scheduler:
<<: *api
command: ["php", "artisan", "schedule:work"]
depends_on:
api: {condition: service_healthy}
client:
image: jhumanj/opnform-client:2.3.0@sha256:1b46bef02db59525e21c9e403805c50839af8c257883430702f1157c6946c1c8
restart: unless-stopped
environment:
NUXT_PUBLIC_APP_URL: http://localhost:8186
NUXT_PUBLIC_API_BASE: http://localhost:8186/api
# Rendering on the server goes back through the ingress: Node cannot
# speak FastCGI. NUXT_API_SECRET is FRONT_API_SECRET renamed.
NUXT_PRIVATE_API_BASE: http://ingress/api
NUXT_API_SECRET: ${FRONT_API_SECRET}
NUXT_PUBLIC_ENV: production
depends_on:
api: {condition: service_healthy}
ingress:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api: {condition: service_healthy}
client: {condition: service_started}
ports:
# Loopback only: no other device on the wifi can reach 8186.
- "127.0.0.1:8186:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/api/healthcheck || exit 1"]
interval: 30s
retries: 5
start_period: 30s
volumes:
opnform-postgres:
opnform-redis:
opnform-storage:
EOF
cd ~/selfhost/opnform && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. Seven services, one published port, three named volumes.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname to
resolve, nothing public to certify, nothing published past loopback to close, and browsers treat
http://localhost as a secure context, so pages that need crypto still work.
8186 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 whole trade, because nobody else can answer the forms
either. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/opnform/compose.yml
```
Assert: `1`, the published-port line on the ingress. Neither database publishes a host port, the
api speaks FastCGI only on the compose network, and the client is reachable only through the
ingress.
## 7. Start and verify
First boot is slow on purpose: the api container waits for PostgreSQL, runs every migration and
caches its config, and the other four wait for it to be healthy. Five minutes is normal, longer on
a laptop pulling five images for the first time.
```bash
cd ~/selfhost/opnform
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8186/api/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8186/api/healthcheck
curl -sS http://localhost:8186/api/content/feature-flags | grep -o '"setup_required":true'
curl -sSL http://localhost:8186/ | grep -c 'Create your admin account'
docker compose ps
```
Assert all five, printing what you got: the loop ends on `200`; health returns
`{"status":"ok","dependencies":{"database":true,"redis":true}}`; the third prints
`"setup_required":true`; the grep prints at least `1` (no account yet, so every path redirects to setup); `ps` shows seven services and no restart loop. If any misses, stop,
run
`docker compose logs --tail 60 api ingress` and name the step to blame: `host not found in upstream`
is the ingress starting before the client, fixed by `docker compose up -d ingress` again, and
`port is already allocated` means something else holds 8186 (`lsof -nP -iTCP:8186 -sTCP:LISTEN`,
`ss -ltnp | grep 8186` on Linux, `netstat -ano | findstr :8186` on Windows). A running container is
not success.
STOP: tell the user to open http://localhost:8186, create their admin account, and confirm once
they are signed in on their workspace. Do not continue until they confirm. No confirmation mail
arrives, this install has no mail server, so have them save the password in a manager first. This
account cannot be made twice: OpnForm refuses registration permanently once one account exists.
Then prove the door shut:
```bash
curl -sS http://localhost:8186/api/content/feature-flags | grep -o '"setup_required":false'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8186/setup
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8186/register
```
Assert: `"setup_required":false`, then `404`, then `302`. Nothing here configured that: upstream's
controller refuses registration for anyone without an invitation once any user exists, so all three
flip together. A `200` from `/setup` means the account was not created, so go back to the STOP. It
matters even on loopback, because step 11 names the one router setting that would expose it.
## 8. First backup and restore
Three artifacts: the database with every form, submission and account; the storage archive with
the attachments a dump does not contain; the config archive that rebuilds the service around both.
```bash
cd ~/selfhost/opnform
docker compose exec -T db pg_dump -U opnform -d opnform | gzip > ~/selfhost/opnform/backups/opnform-db-$(date +%F).sql.gz
docker compose exec -T api tar -czf - -C /usr/share/nginx/html storage > ~/selfhost/opnform/backups/opnform-storage-$(date +%F).tar.gz
tar -C ~/selfhost/opnform -czf ~/selfhost/opnform/backups/opnform-config-$(date +%F).tar.gz compose.yml .env nginx
ls -lh ~/selfhost/opnform/backups/
```
Assert: all three exist, all three non-empty, print all three sizes. Nothing stops, because
`pg_dump` snapshots a running database consistently. Redis is not in the backup: cache and queue,
not data, so a submission still queued when this ran is not in it either.
All three sit on the same disk as the data, which is not a backup, and 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 all three there with `cp`. In Git Bash a Windows drive is written `/d/Backups`,
not `D:\Backups`; confirm it exists first. Assert: the user confirms all three filenames are there,
or say plainly that this install has no backup.
To restore, in this order, because the api migrates the moment it reaches a database: untar the
config archive into ~/selfhost/opnform so compose.yml and .env are back first, `docker compose down
-v`, the one place `-v` belongs, `docker compose up -d db redis`, wait for healthy, pipe `gunzip -c`
on the `.sql.gz` into `docker compose exec -T db psql -U opnform -d opnform`, `docker compose up
-d`, wait for step 7's health check, then pipe `gunzip -c` on the storage archive into
`docker compose exec -T api tar -xzf - -C /usr/share/nginx/html`. Every answer anyone ever sent
them is a row in that dump.
## 9. Updating later
Releases: https://github.com/OpnForm/OpnForm/releases. Release tag `v2.3.0` = image tag `2.3.0`,
the digits without the `v`. Take all three backups first, then edit the `jhumanj/opnform-api`
line in the `x-api` block and the `jhumanj/opnform-client` line to the new tag and digest. Both
move together: a client built against a different API is the failure that looks like a broken
login.
```bash
cd ~/selfhost/opnform
docker compose pull
docker compose up -d
docker compose logs --tail 40 api
docker compose restart ingress
```
That last line is not optional; step 10 says why. OpnForm migrates on the way up, so watch the api
log until it settles, then re-run step 7's health check. Leave postgres and redis alone: a database
major is a separate migration with its own restore.
## 10. What will probably go wrong
Nothing, for about six minutes, and it will look exactly like a failure. On the first `up -d` I
watched `docker compose ps` show six containers waiting while the api sat there, and
http://localhost:8186 refused the connection the whole time. That is the design: the api runs every
migration and caches its configuration before it answers a health check at all, and the ingress will
not start until it does, which is why step 7 has a loop to read. The other half is Docker Desktop,
which does not start with the session unless told to, so after a reboot the same connection refused
means nothing is running. Turn on start-at-login, and after any reboot run
`cd ~/selfhost/opnform && 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 rebind 8186 to 0.0.0.0 so a phone on the wifi can load a form, and do not point `APP_URL`,
`FRONT_URL` or `NUXT_PUBLIC_APP_URL` at this machine's LAN address. Those values have to agree,
and together they put a form builder on every network this laptop joins.
- Do not configure SMTP, and do not set `OPEN_AI_API_KEY`, the hCaptcha or reCAPTCHA keys,
`GOOGLE_CLIENT_ID` or the `AWS_` variables. Each is an account somewhere else and a second
failure mode; everything core works without them.
- Do not activate a self-hosted Enterprise licence and do not set `CUSTOM_CODE_ENABLE_SELF_HOSTED`.
The first is a purchase the user makes, the second turns user-supplied code loose in a page.compose.local.ymlthe services, pinned · local layout136 lines
# OpnForm · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://docs.opnform.com/deployment/docker,
# https://docs.opnform.com/configuration/environment-variables and
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker-compose.yml
#
# Seven services on the computer you are sitting at, upstream's own shape. The
# api image is php-fpm on 9000, so the nginx ingress marries FastCGI to the
# Nuxt client under one origin and is the only service with a published port.
# api, worker and scheduler are one image under three commands, and the queue
# is where an ordinary submission is written. The ingress config is a relative
# bind mount so you can open it in Finder or Explorer; the data lives in named
# volumes, because each of those images chowns its own directory to a uid a
# home bind mount cannot grant on Windows. Digests read 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-api: &api
image: jhumanj/opnform-api:2.3.0@sha256:4b71e200d420c7cd2f3bbc7d8d9431de922c5edb8e49a7679c2d09c858fa7329
restart: unless-stopped
env_file: ./.env
environment:
APP_ENV: production
APP_DEBUG: "false"
SELF_HOSTED: "true"
APP_URL: http://localhost:8186
FRONT_URL: http://localhost:8186
LOG_CHANNEL: errorlog
LOG_LEVEL: warning
DB_CONNECTION: pgsql
DB_HOST: db
DB_DATABASE: opnform
DB_USERNAME: opnform
REDIS_HOST: redis
CACHE_DRIVER: redis
QUEUE_CONNECTION: redis
SESSION_DRIVER: redis
LOCAL_FILESYSTEM_VISIBILITY: public
# No SMTP, so mail lands in the container log rather than nowhere.
MAIL_MAILER: log
# Upstream's setup script refuses a production deploy with this true.
JWT_SKIP_IP_UA_VALIDATION: "false"
# Docker's bridge range, so Laravel reads the address the ingress
# forwarded rather than the ingress container's own. Never "*".
TRUSTED_PROXIES: 172.16.0.0/12
OPNFORM_ANONYMOUS_TELEMETRY_DISABLED: "true"
volumes:
- opnform-storage:/usr/share/nginx/html/storage
services:
db:
image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
restart: unless-stopped
environment:
POSTGRES_DB: opnform
POSTGRES_USER: opnform
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- opnform-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opnform -d opnform"]
interval: 10s
retries: 30
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\""]
volumes:
- opnform-redis:/data
healthcheck:
test: ["CMD-SHELL", 'redis-cli -a "$$REDIS_PASSWORD" --no-auth-warning ping | grep -q PONG']
interval: 10s
retries: 30
api:
<<: *api
depends_on:
db: {condition: service_healthy}
redis: {condition: service_healthy}
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
interval: 30s
timeout: 15s
retries: 5
# Long: it migrates before it answers, and the other four wait here.
start_period: 300s
worker:
<<: *api
command: ["php", "artisan", "queue:work"]
depends_on:
api: {condition: service_healthy}
scheduler:
<<: *api
command: ["php", "artisan", "schedule:work"]
depends_on:
api: {condition: service_healthy}
client:
image: jhumanj/opnform-client:2.3.0@sha256:1b46bef02db59525e21c9e403805c50839af8c257883430702f1157c6946c1c8
restart: unless-stopped
environment:
NUXT_PUBLIC_APP_URL: http://localhost:8186
NUXT_PUBLIC_API_BASE: http://localhost:8186/api
# Rendering on the server goes back through the ingress: Node cannot
# speak FastCGI. NUXT_API_SECRET is FRONT_API_SECRET renamed.
NUXT_PRIVATE_API_BASE: http://ingress/api
NUXT_API_SECRET: ${FRONT_API_SECRET}
NUXT_PUBLIC_ENV: production
depends_on:
api: {condition: service_healthy}
ingress:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api: {condition: service_healthy}
client: {condition: service_started}
ports:
# Loopback only: no other device on the wifi can reach 8186.
- "127.0.0.1:8186:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/api/healthcheck || exit 1"]
interval: 30s
retries: 5
start_period: 30s
volumes:
opnform-postgres:
opnform-redis:
opnform-storage:agent-readable mirror: /self-host/tally.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, pinned131 lines
# OpnForm · the deterministic fallback. Authored by caniselfhostit from
# https://docs.opnform.com/deployment/docker,
# https://docs.opnform.com/configuration/environment-variables and
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker-compose.yml
#
# Seven services, upstream's own shape. The api image is php-fpm on 9000, so
# the nginx ingress speaks FastCGI to it and proxies the rest to the Nuxt
# client under one origin; the host's Caddy fronts that on 8186. api, worker
# and scheduler are one image under three commands, and the queue is not
# optional: an ordinary submission is dispatched to it, not written during the
# request. Digests read from Docker Hub on 2026-08-14; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
x-api: &api
image: jhumanj/opnform-api:2.3.0@sha256:4b71e200d420c7cd2f3bbc7d8d9431de922c5edb8e49a7679c2d09c858fa7329
restart: unless-stopped
env_file: /srv/opnform/.env
environment:
APP_ENV: production
APP_DEBUG: "false"
SELF_HOSTED: "true"
LOG_CHANNEL: errorlog
LOG_LEVEL: warning
DB_CONNECTION: pgsql
DB_HOST: db
DB_DATABASE: opnform
DB_USERNAME: opnform
REDIS_HOST: redis
CACHE_DRIVER: redis
QUEUE_CONNECTION: redis
SESSION_DRIVER: redis
LOCAL_FILESYSTEM_VISIBILITY: public
# Mail to the container log, not nowhere. Upstream's setup script refuses
# a production deploy with JWT validation skipped. The bridge range lets
# Laravel read the forwarded visitor address; never "*".
MAIL_MAILER: log
JWT_SKIP_IP_UA_VALIDATION: "false"
TRUSTED_PROXIES: 172.16.0.0/12
OPNFORM_ANONYMOUS_TELEMETRY_DISABLED: "true"
volumes:
- opnform-storage:/usr/share/nginx/html/storage
services:
db:
image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
restart: unless-stopped
environment:
POSTGRES_DB: opnform
POSTGRES_USER: opnform
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- opnform-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opnform -d opnform"]
interval: 10s
retries: 30
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\""]
volumes:
- opnform-redis:/data
healthcheck:
test: ["CMD-SHELL", 'redis-cli -a "$$REDIS_PASSWORD" --no-auth-warning ping | grep -q PONG']
interval: 10s
retries: 30
api:
<<: *api
depends_on:
db: {condition: service_healthy}
redis: {condition: service_healthy}
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
interval: 30s
timeout: 15s
retries: 5
# Long: it migrates before it answers, and the other four wait here.
start_period: 300s
worker:
<<: *api
command: ["php", "artisan", "queue:work"]
depends_on:
api: {condition: service_healthy}
scheduler:
<<: *api
command: ["php", "artisan", "schedule:work"]
depends_on:
api: {condition: service_healthy}
client:
image: jhumanj/opnform-client:2.3.0@sha256:1b46bef02db59525e21c9e403805c50839af8c257883430702f1157c6946c1c8
restart: unless-stopped
environment:
NUXT_PUBLIC_APP_URL: ${APP_URL}
NUXT_PUBLIC_API_BASE: ${APP_URL}/api
# Rendering on the server goes back through the ingress: Node cannot
# speak FastCGI. NUXT_API_SECRET is FRONT_API_SECRET renamed.
NUXT_PRIVATE_API_BASE: http://ingress/api
NUXT_API_SECRET: ${FRONT_API_SECRET}
NUXT_PUBLIC_ENV: production
depends_on:
api: {condition: service_healthy}
ingress:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
volumes:
- /srv/opnform/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api: {condition: service_healthy}
client: {condition: service_started}
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8186.
- "127.0.0.1:8186:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/api/healthcheck || exit 1"]
interval: 30s
retries: 5
start_period: 30s
volumes:
opnform-postgres:
opnform-redis:
opnform-storage:Caddyfilethe hostname and TLS31 lines
# OpnForm · the Caddy site block for this service. Authored by caniselfhostit
# from https://docs.opnform.com/deployment/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is APP_URL, FRONT_URL and
# NUXT_PUBLIC_APP_URL at once, and every form link is it plus /forms/ and a
# slug, so it is the one value here you cannot change your mind about later.
<DOMAIN> {
# No X-Frame-Options on purpose: OpnForm ships an embed script, so a form
# is meant to run inside somebody else's 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
# Matches the ingress and the api image's own 50M PHP upload ceiling.
request_body {
max_size 50MB
}
# 8186 is the loopback port compose publishes for the nginx ingress: not a
# container port, and not open in the firewall. TRUSTED_PROXIES is what
# lets Laravel believe the X-Forwarded-For and -Proto that Caddy sends.
reverse_proxy 127.0.0.1:8186
}install.shthe same install, no agent215 lines
#!/usr/bin/env bash
# OpnForm · 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.opnform.com/deployment/docker
# https://docs.opnform.com/configuration/environment-variables
# https://docs.opnform.com/deployment/self-hosted-license
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker-compose.yml
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker/nginx.conf
#
# Five secrets are generated here, on this machine: the Laravel application
# key, the JWT signing key, the shared secret between the Nuxt server and the
# API, the PostgreSQL password and the Redis password. All five go into
# /srv/opnform/.env with mode 600 and none of them is ever printed.
#
# DOMAIN_HOST becomes APP_URL, FRONT_URL and NUXT_PUBLIC_APP_URL at once, and
# every form link is that hostname plus /forms/ and a slug. Choose it once:
# changing it later breaks links you have already sent out.
#
# This script leaves the setup page OPEN, because only a human with a browser
# can create the first account, and whoever reaches the hostname first becomes
# the owner of this instance. The closing summary gives you the three commands
# that prove it closed. Do it the same hour.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/opnform}"
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 4096 ] || die "only ${avail_mb} MB of RAM available; seven containers with three PHP processes want 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}')" || resolved=""
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# The ingress config is written before anything starts: Docker makes a
# directory where a missing bind-mount file should be, and nginx then refuses
# a config that is a folder.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/nginx"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
cat > "$APP_DIR/nginx/default.conf" <<'NGINXCONF'
# OpnForm · the ingress, authored by caniselfhostit from
# https://github.com/OpnForm/OpnForm/blob/v2.3.0/docker/nginx.conf
# The map strips /api before PHP sees it, since Laravel's routes are at the
# root; `root` is a path inside the api container and only builds
# SCRIPT_FILENAME, so nothing static is served from this one.
map $request_uri $api_uri {
~^/api(/.*$) $1;
default $request_uri;
}
server {
listen 80;
root /usr/share/nginx/html/public;
client_max_body_size 50m;
location / {
proxy_http_version 1.1;
proxy_pass http://client:3000;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location ~/(api|open|local\/temp|forms\/assets)/ {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass api:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param REQUEST_URI $api_uri;
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
}
}
NGINXCONF
# --- 3. Generate the five secrets, on the server -----------------------------
#
# APP_KEY has a shape: base64: followed by 32 random bytes in base64, which is
# what `php artisan key:generate --show` produces. The other four are hex,
# because two of them travel inside connection strings. Read them later with
# sudo grep -E 'APP_KEY|JWT_SECRET|FRONT_API_SECRET' /srv/opnform/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
APP_URL=https://${DOMAIN_HOST}
FRONT_URL=https://${DOMAIN_HOST}
APP_KEY=base64:$(openssl rand -base64 32)
JWT_SECRET=$(openssl rand -hex 32)
FRONT_API_SECRET=$(openssl rand -hex 32)
DB_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-opnform"
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 8186, 5432, 6379, 9000 and 3000 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; everything else stays closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# First boot is slow on purpose: the api container waits for PostgreSQL, runs
# every migration and caches its configuration, and the other four wait for it
# to report healthy.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/api/healthcheck"
for _ in $(seq 1 60); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/healthcheck" 2>/dev/null)" || code="000"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/api/healthcheck answered ${code:-nothing}. Check: docker compose logs --tail 60 api ingress"
# The health body names both dependencies, which is the only way to tell a
# container that never started from one that reached neither database.
curl -sS "https://${DOMAIN_HOST}/api/healthcheck" | grep -q '"dependencies":{"database":true,"redis":true}' \
|| die "/api/healthcheck answered 200 without both dependencies up. Check: docker compose logs --tail 60 api"
# Nobody has claimed this instance yet, which is exactly why the summary below
# is urgent rather than informational.
curl -sS "https://${DOMAIN_HOST}/api/content/feature-flags" | grep -q '"setup_required":true' \
|| die "setup_required is not true: an account already exists on this instance, or the client is not reachable"
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db pg_dump -U opnform -d opnform | gzip > "$APP_DIR/backups/opnform-db-${STAMP}.sql.gz"
docker compose exec -T api tar -czf - -C /usr/share/nginx/html storage > "$APP_DIR/backups/opnform-storage-${STAMP}.tar.gz"
sudo tar -czf "$APP_DIR/backups/opnform-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env nginx -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/opnform-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/opnform-storage-${STAMP}.tar.gz" ] || die "the storage archive is empty"
cat <<-DONE
OpnForm is answering at https://${DOMAIN_HOST}
1. The setup page is OPEN right now, to anyone who finds the hostname,
and whoever fills it in owns this instance. There is no second
chance: OpnForm refuses public registration permanently once one
account exists. Open https://${DOMAIN_HOST} in a browser, create
your admin account, then check that the door shut:
curl -sS https://${DOMAIN_HOST}/api/content/feature-flags | grep -o '"setup_required":false'
curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/setup
curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/register
Those must print "setup_required":false, then 404, then 302. Do this
the same hour, not tomorrow.
2. Your five secrets are in $APP_DIR/.env, mode 600. None was printed
here. Read them yourself with
sudo grep -E 'APP_KEY|JWT_SECRET' $APP_DIR/.env
3. First backup written to $APP_DIR/backups: a PostgreSQL dump, a
storage archive holding respondent attachments, and a config archive
carrying .env and the live Caddy config. They are on the same disk as
the data, which is not a backup. Copy them off tonight:
scp vps:$APP_DIR/backups/* ~/backups/opnform/
4. No mail server is configured, so password reset and response
notifications do not work. That one account is your whole way back in.
5. A licence-free self-hosted instance is capped at two users in total.
Everyone after you joins by invitation from inside the workspace.
DONEWhat 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 Tally.
- Seven containers, and that count is upstream's rather than ours. The API image is php-fpm on port 9000, not a web server, so an nginx ingress has to marry it to the Nuxt front end under one origin, and a queue worker and a scheduler run the same image beside it. That is one database to dump, one attachments volume to archive, and seven things that can be unhappy on a Tuesday morning.
- Two people, then you are buying something. Upstream caps a licence-free self-hosted instance at two users in total, counting pending invitations, and the third is refused with a 403 naming the Enterprise licence. If the plan was a shared team inbox for form responses, price that in before you start.
- Open source with a fenced-off room. The tree is AGPL-3.0 except for api/app/Enterprise/, which carries a separate proprietary Enterprise licence and needs an active subscription to use in production; that split is why GitHub reports the repository as unclassified rather than AGPL. The code ships in the image either way, so read the carve-out before you build on it.
- No mail, by choice. This install configures no SMTP server, which is what keeps it to one evening's reading rather than two, and the core loop is fine: the account is made, the sign-in holds, submissions land. What you do not get is the email telling you a response arrived, the verification message and password reset. That first account is the whole way back in.
- A self-hosted instance resolves to upstream's Pro feature tier, which is where a custom domain and form analytics live. Custom CSS, partial submissions and version history sit a tier above that, and upstream's own self-hosted licence page lists branding removal under the Enterprise white-label group, so do not assume every switch in the builder will move.
Where this came from
“Free self-hosted instances are limited to 2 users total across the whole instance.”
- A self-hosted instance allows registration only while no user exists: once the first account is created, upstream's register controller answers anyone without a workspace invitation with 400 and the message Registration is not allowed. source
- The public feature-flags endpoint reports setup_required, which is true only while the user table is empty, and a global middleware redirects every path to /setup while it is true, so the flip from true to false is the closure evidence. source
- The /healthcheck route is registered only when SELF_HOSTED is true, and it reports the database and Redis separately, answering 503 if either is unreachable. source
- The API image runs php-fpm on port 9000 rather than an HTTP server, so upstream's own ingress speaks FastCGI to it, strips the /api prefix before Laravel sees the request and proxies everything else to the Nuxt client on port 3000. source
- A submission to a form with no redirect URL, no editable submissions and no PDF download is dispatched to the queue rather than written during the request, which is why the queue worker is not an optional container. source
- APP_URL, FRONT_URL, NUXT_PUBLIC_APP_URL and NUXT_PUBLIC_API_BASE must all carry the real public origin, TRUSTED_PROXIES must name the proxy's address or CIDR and never *, and OPNFORM_ANONYMOUS_TELEMETRY_DISABLED turns off the anonymous usage ping. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Tally?
Not Tally 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 OpnForm. A no-code form builder on your own hostname, with unlimited forms, file uploads and every answer in a database you hold. The install is ongoing ops: 7 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 300 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 Tally?
OpnForm. A no-code form builder on your own hostname, with unlimited forms, file uploads and every answer in a database you hold. The closest match to the shape Tally actually sells: a block-style builder a non-developer can drive, unlimited forms and submissions with no meter on either, file uploads, conditional logic, webhooks and an embed script, on a hostname you own. It is the honest first answer here and also the expensive one to run, because the API image is php-fpm rather than a web server, so the shipped stack is seven containers with an nginx ingress in front. Two hard limits to price in before you start: a licence-free self-hosted instance is capped at two users in total, and this install configures no mail, so the response notification Tally sends you for free does not arrive. Also evaluated and ranked below it: HeyForm, Formbricks. The prompt on this page installs OpnForm only. OpnForm 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 Tally?
4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. OpnForm itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Tally Pro, $24/mo — $288 a year.
How hard is it really?
ONGOING OPS — 3+ hours, then ongoing. The rule that produced that verdict: five or more containers. Five or more services is a stack. On any given morning one of them is unhappy, and you are the only person who is going to notice. The tier is derived from seven countable facts about the OpnForm install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run OpnForm 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 OpnForm 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:8186/forms/ and a slug, 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-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.