Can I self-host Typeform?
YES, IF · ONGOING OPS— setup effort 4 of 4YES, IF — it's called Formbricks. It takes one prompt, a 4096 MB VPS, and about 240 minutes. That is $129 a month you stop paying Typeform — $1,548 a year on the Business plan.
Why people pay for Typeform
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.
Typeform sells completion rate. One question at a time, on a page that looks like somebody designed it, is measurably easier to finish than a wall of fields, and the people who buy it are buying answers rather than software. The bill is metered on the thing you cannot control: how many people reply. A form that does well takes you up a tier, which is a strange incentive to be on the wrong side of.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | Described in the pricing page FAQ as a way to explore Typeform with basic features and a limited number of responses per month. No plan card and no response figure on the page we read. |
| Basic | $39/mo | 100 responses a month, 1 seat. $28/mo billed annually ($336/yr), which the page advertises as a $132/yr saving. |
| Plus | $79/mo | 1,000 responses a month, 3 seats. $56/mo billed annually ($672/yr), advertised as a $276/yr saving. |
| Businessthe plan this page prices against | $129/mo | 10,000 responses a month, 5 seats. $91/mo billed annually ($1,092/yr), advertised as a $456/yr saving. |
| Talent | $169/mo | The hiring-focused tier: 3,000 responses a month, 3 seats. $119/mo billed annually ($1,428/yr). |
| Growth Flow | $379/mo | 10,000 responses a month, 5 seats, adding enrichment, automations and SMS follow-ups. $266/mo billed annually, after a 14-day trial. |
| Enterprise | quote only | Quote only. The page lists custom response limits, SSO, HIPAA and custom domains here. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Formbricks
One project, named before the prompt, so you know what you are about to install.
Link surveys and in-product feedback on your own domain, with no monthly response cap deciding your bill.
The closest match to what people actually buy Typeform for: one-question-at-a-time link surveys, a builder with logic and hidden fields, and an in-app widget for surveying people who are already using your product. Self-hosted it has no response cap at all, which removes the meter that decides your Typeform bill. The price is the install: version 5 is a seven-service stack, and Hub and Cube are not optional parts of it.
The swap
You'd run
Formbricks
ONGOING OPS · ~240 min to running · 4096 MB RAM
Typeform Business · vendor list price · checked 2026-08-06 · 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
- ~240 min3+ hours, then ongoing, through the first backup
The prompt
Two paths to the same Formbricks: 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
364 lines · 14,951 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 Formbricks 5.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 it once and stop until they answer. Say why: it
becomes `WEBAPP_URL`, the front of every survey link they send, so a link already in somebody's
inbox dies if they change it. Its A record must point at this server now.
Formbricks 5 needs 4096 MB of RAM available and 20 GB free on /srv. That floor is upstream's own
Helm limits added up: 2 GB web, 1 GB Cube, 512 MB Hub, 192 MB Valkey, plus PostgreSQL. Both
architectures are published.
```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>
```
If available RAM is under 4096 MB or free disk under 20 GB, print both and stop. If `dig +short`
prints nothing, print that and stop. Do not install and hope.
## 2. Layout
Cube reads two files upstream ships in their repository, not in their image. Fetch both at the
pinned tag and verify them before they are mounted.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/formbricks /srv/formbricks/backups /srv/formbricks/cube /srv/formbricks/cube/schema
sudo install -d -m 700 /srv/formbricks/postgres /srv/formbricks/redis
cd /srv/formbricks/cube
for f in cube.js schema/FeedbackRecords.js; do curl -fsSL -o "$f" "https://raw.githubusercontent.com/formbricks/formbricks/5.3.0/docker/cube/$f"; done
cat > SHA256SUMS <<'EOF'
723eea0f581200a686f854ff47b38f2e92bbfe5d802338049afaa061f154a335 cube.js
c3322a3739ee1cc57224139f502395a20dcbe4dd71e331be41d687ffdfe140f8 schema/FeedbackRecords.js
EOF
sha256sum -c SHA256SUMS
ls -la /srv/formbricks
```
Assert: `sha256sum -c` prints two lines ending `OK`; print both. On `FAILED`, stop: those are
not the bytes recorded on 2026-08-06. `ls -la` shows `backups` and `cube` owned by the login
user, `postgres` and `redis` at mode `700` owned by root. Leave those two: both images chown
their own data directory on first start.
## 3. Secrets
Six: the PostgreSQL password and five keys the app requires. Generate all six here, print none
of them, and keep them out of your summary and out of every log line. Hex, because upstream caps
three at 32 bytes.
```bash
umask 077
cat > /srv/formbricks/.env <<EOF
WEBAPP_URL=https://<DOMAIN>
NEXTAUTH_URL=https://<DOMAIN>
DB_PASSWORD=$(openssl rand -hex 32)
NEXTAUTH_SECRET=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
CRON_SECRET=$(openssl rand -hex 32)
HUB_API_KEY=$(openssl rand -hex 32)
CUBEJS_API_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/formbricks/.env
umask 022
ls -l /srv/formbricks/.env
```
Assert: mode `-rw-------`. `ENCRYPTION_KEY` matters most: upstream uses it for two-factor
secrets, single-use survey links and audit-log hashing, so a database restored without it is
unreadable. Step 8 gets a copy off the box.
## 4. compose.yml
```bash
cat > /srv/formbricks/compose.yml <<'EOF'
# Formbricks · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# compose setup ....... https://formbricks.com/docs/self-hosting/setup/docker
# variable reference .. https://formbricks.com/docs/self-hosting/configuration/environment-variables
#
# Seven services: five that stay up, two migration jobs that run in order and
# exit. Upstream makes Hub, Cube and Valkey mandatory in version 5. Only 8110
# is published, on loopback. Digests read 2026-08-06, all five multi-arch.
#
# Three deliberate trims from upstream's compose: no validate-env prefix on
# the migrate job (the web container runs it at startup), no direct postgres
# depends_on for hub and cube (the migration chain already gates them), and
# no saml-connection mount (paid edition, out of scope).
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: formbricks
services:
postgres:
image: pgvector/pgvector:0.8.6-pg18@sha256:691673308c99d2161ba298736f3147f1f22d79de2fb7ec93ae9b4afcab870b62
restart: unless-stopped
environment:
POSTGRES_DB: formbricks
POSTGRES_USER: formbricks
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/formbricks/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U formbricks -d formbricks"]
interval: 10s
retries: 30
redis:
image: valkey/valkey:9.0.5-alpine@sha256:0cb61366757e2bcd26500b4e8bb63cbd7117610e3e4f05aacb3c812511da7632
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- /srv/formbricks/redis:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 30
formbricks-migrate:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
command: ["node", "packages/database/dist/scripts/apply-migrations.js"]
depends_on:
postgres:
condition: service_healthy
hub-migrate:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
entrypoint: ["sh", "-c"]
command: ['/usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"']
depends_on:
formbricks-migrate:
condition: service_completed_successfully
hub:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
restart: unless-stopped
environment:
API_KEY: ${HUB_API_KEY}
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
depends_on:
hub-migrate:
condition: service_completed_successfully
cube:
image: cubejs/cube:v1.6.6@sha256:746a381c5deb1f33500c84bed357ebe68aa08acc5030939f9e9efd35796d368c
restart: unless-stopped
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: postgres
CUBEJS_DB_NAME: formbricks
CUBEJS_DB_USER: formbricks
CUBEJS_DB_PASS: ${DB_PASSWORD}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
CUBEJS_JWT_ISSUER: formbricks-web
CUBEJS_JWT_AUDIENCE: formbricks-cube
CUBEJS_DEFAULT_API_SCOPES: meta,data
CUBEJS_EXTERNAL_DEFAULT: "false"
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- /srv/formbricks/cube/cube.js:/cube/conf/cube.js:ro
- /srv/formbricks/cube/schema:/cube/conf/model:ro
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4000/readyz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
retries: 18
start_period: 40s
depends_on:
hub-migrate:
condition: service_completed_successfully
formbricks:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
restart: unless-stopped
env_file: /srv/formbricks/.env
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
REDIS_URL: redis://redis:6379
HUB_API_URL: http://hub:8080
CUBEJS_API_URL: http://cube:4000
EMAIL_VERIFICATION_DISABLED: "1"
PASSWORD_RESET_DISABLED: ${PASSWORD_RESET_DISABLED:-1}
SKIP_STARTUP_MIGRATION: "true"
ports:
- "127.0.0.1:8110:3000"
depends_on:
formbricks-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
cube:
condition: service_healthy
hub:
condition: service_started
EOF
cd /srv/formbricks && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. Nothing here is optional: Hub and Cube joined the baseline stack at
version 5, and the app will not start without a Redis URL.
## 5. Caddy and TLS
Append the block below with `<DOMAIN>` replaced by the real hostname. Copy first: a syntax
error here takes down every other site.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-formbricks
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Formbricks · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://formbricks.com/docs/self-hosting/configuration/domain-configuration
# and https://caddyserver.com/docs/automatic-https
<DOMAIN> {
# No X-Frame-Options and no frame-ancestors on purpose: link surveys are
# meant to be embedded in other people's pages.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8110 is the loopback port compose publishes. Not open in the firewall.
reverse_proxy 127.0.0.1:8110
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: both exit 0. On failure restore /etc/caddy/Caddyfile.before-formbricks, reload, and
report what it said. Caddy gets the certificate on the first request and renews it
itself, so there is nothing to schedule.
## 6. Firewall
Two ports open, both Caddy's, idempotent on a Prompt Zero box:
```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 and redirects, 443/tcp is the way in, 443/udp is HTTP/3. 8110
stays closed because it binds to 127.0.0.1; 5432, 6379, 8080 and 4000 because compose publishes
none. Assert: `Status: active`, rules for 80, 443/tcp and 443/udp, none for those five.
## 7. Start and verify
The migration jobs run and exit first, then Cube must be healthy before the web container
starts. On a cold pull this takes minutes.
```bash
cd /srv/formbricks
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/health
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -c 'Welcome to Formbricks'
docker compose ps -a
```
Assert all five, printing what you got: the loop ends on `200`; health returns `{"status":"ok"}`;
the redirect lands on `https://<DOMAIN>/setup/intro`; the grep prints at least `1`: that screen
carries the heading `Welcome to Formbricks!` above a `Get started` button; `ps -a` shows
both migration containers `exited (0)` and the other five up. If any misses, stop, run
`docker compose logs --tail 40 formbricks cube hub-migrate`, and name the step to blame:
hub-migrate exiting non-zero is step 3 and an empty `DB_PASSWORD`, cube stuck unhealthy is
step 2 and a failed checksum. A running container is not success.
STOP: tell the user to open https://<DOMAIN>, click `Get started`, create their administrator
account and organization, and wait. Do not continue until they confirm. It is the only moment
that account can be made: signup stays closed afterwards and only an owner or admin can invite
anyone. No SMTP means no reset mail, so have them save the password in a manager first.
Then prove the door shut:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup/intro
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/
```
Assert: `404`, then `https://<DOMAIN>/auth/login`. The wizard answers only while the user table
is empty. Both must pass before you report success.
## 8. First backup and restore
Two artifacts: the database holds every survey, response and account; the config archive holds
what rebuilds the service around them, `ENCRYPTION_KEY` included.
```bash
cd /srv/formbricks
docker compose exec -T postgres pg_dump -U formbricks -d formbricks | gzip > /srv/formbricks/backups/formbricks-db-$(date +%F).sql.gz
sudo tar -czf /srv/formbricks/backups/formbricks-config-$(date +%F).tar.gz -C /srv/formbricks compose.yml .env cube -C /etc/caddy Caddyfile
ls -lh /srv/formbricks/backups/
```
Assert: both exist, both non-empty, print both sizes. Nothing stops, because `pg_dump`
snapshots a running database consistently. Valkey is not backed up: cache and jobs, not data. A
backup on the same disk is not a backup, so run this from the user's machine:
```bash
mkdir -p ~/backups/formbricks
scp vps:/srv/formbricks/backups/* ~/backups/formbricks/
```
To restore: `docker compose down`, `sudo rm -rf /srv/formbricks/postgres`, recreate it as in
step 2, untar the config archive there so .env is back first, `docker compose up -d postgres`,
wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U formbricks -d formbricks`, then `docker compose up -d`.
Say the stakes: a dump without that `.env` is rows nobody can decrypt, so they travel together.
## 9. Updating later
Releases are at https://github.com/formbricks/formbricks/releases; the Hub and Cube versions
that pair with each are in `charts/formbricks/values.yaml` in that tag. Back up first, then edit
the image lines in compose.yml:
```bash
cd /srv/formbricks
docker compose pull
docker compose up -d
docker compose logs --tail 40 formbricks-migrate hub-migrate formbricks
```
Both migration jobs rerun on every start, so watch them exit 0, then re-run step 7's check.
## 10. What will probably go wrong
The wait. I brought this up on a 4 GB box, saw two containers in `Created` and one `starting`,
and went looking for what I had broken. Nothing was: the app is gated on Cube being healthy,
Cube has a 40 second start period before its first check counts, and is gated on both migration
jobs finishing. That chain ran past six minutes before /health answered. Let the loop in step 7
run all forty times before deciding it is broken.
## 11. Out of scope
- Do not configure SMTP. `EMAIL_VERIFICATION_DISABLED` and `PASSWORD_RESET_DISABLED` are 1,
upstream's default, and the survey loop needs no mail.
- Do not configure S3 or the bundled RustFS storage. That is a second subdomain and another
service; without it the file-upload and image questions stay off.
- Do not enable the `qwen` or `taxonomy` profiles, set `ENTERPRISE_LICENSE_KEY`, or configure
SSO, SAML or OIDC. The AI profiles are opt-in and one wants an NVIDIA GPU; the rest is the
paid edition, and this is the community one.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 Formbricks 5.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 `WEBAPP_URL`, the front of every survey link you
send out, so a link already sitting in somebody's inbox stops working if you change it later.
Pick the hostname 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. That RAM floor is upstream's own Helm limits added up: 2 GB web,
1 GB Cube, 512 MB Hub, 192 MB Valkey, plus PostgreSQL.
If you do not: 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 name that does not
resolve and failed attempts count against a rate limit you cannot see. Under 4096 MB available
is the one number not to argue with: five services on a 2 GB box means the kernel picks which
one dies, and it usually picks the web app after ten minutes of looking fine.
## 2. Layout
Cube reads two files upstream ships in their repository, not in their image. This block makes
the tree, fetches both at the pinned tag, and checks them against digests recorded on
2026-08-06.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/formbricks /srv/formbricks/backups /srv/formbricks/cube /srv/formbricks/cube/schema
sudo install -d -m 700 /srv/formbricks/postgres /srv/formbricks/redis
cd /srv/formbricks/cube
for f in cube.js schema/FeedbackRecords.js; do curl -fsSL -o "$f" "https://raw.githubusercontent.com/formbricks/formbricks/5.3.0/docker/cube/$f"; done
cat > SHA256SUMS <<'EOF'
723eea0f581200a686f854ff47b38f2e92bbfe5d802338049afaa061f154a335 cube.js
c3322a3739ee1cc57224139f502395a20dcbe4dd71e331be41d687ffdfe140f8 schema/FeedbackRecords.js
EOF
sha256sum -c SHA256SUMS
ls -la /srv/formbricks
```
You should see: `cube.js: OK` and `schema/FeedbackRecords.js: OK`, then a listing with `backups`
and `cube` owned by you and `postgres` and `redis` at `drwx------` owned by root.
If you do not: `FAILED` means the bytes on disk are not the bytes we recorded, so stop there,
because this is configuration for a service that queries your database. Leave `postgres` and
`redis` owned by root on purpose: both images chown their own data directory the first time they
start, and one you have already chowned to yourself makes PostgreSQL refuse to initialise.
## 3. Secrets
Six secrets: the PostgreSQL password and five keys the application requires. All six are
generated here, on the server, straight into a file only you can read. Replace `<DOMAIN>` on the
first two lines with your real hostname before you paste.
```bash
umask 077
cat > /srv/formbricks/.env <<EOF
WEBAPP_URL=https://<DOMAIN>
NEXTAUTH_URL=https://<DOMAIN>
DB_PASSWORD=$(openssl rand -hex 32)
NEXTAUTH_SECRET=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
CRON_SECRET=$(openssl rand -hex 32)
HUB_API_KEY=$(openssl rand -hex 32)
CUBEJS_API_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/formbricks/.env
umask 022
ls -l /srv/formbricks/.env
```
You should see: mode `-rw-------`, your own username twice, and the path.
If you do not: `-rw-r--r--` means `umask 077` did not take effect, which happens if you pasted
the lines separately in different shells. Run `chmod 600 /srv/formbricks/.env` and carry on. If
the file already existed from an earlier attempt this block has overwritten all six values,
which is fine before the database exists and a problem afterwards: PostgreSQL keeps the password
it was created with, so a changed `DB_PASSWORD` on an existing volume shows up as an
authentication failure in the app log rather than as anything about passwords.
Do not paste that file, any of those six values, or any command output containing them into this
chat window. `ENCRYPTION_KEY` is the one to understand: upstream uses it for two-factor secrets,
single-use survey links and audit-log hashing, so a database restored without this exact file is
one you cannot fully read.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/formbricks/compose.yml <<'EOF'
# Formbricks · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# compose setup ....... https://formbricks.com/docs/self-hosting/setup/docker
# variable reference .. https://formbricks.com/docs/self-hosting/configuration/environment-variables
#
# Seven services: five that stay up, two migration jobs that run in order and
# exit. Upstream makes Hub, Cube and Valkey mandatory in version 5. Only 8110
# is published, on loopback. Digests read 2026-08-06, all five multi-arch.
#
# Three deliberate trims from upstream's compose: no validate-env prefix on
# the migrate job (the web container runs it at startup), no direct postgres
# depends_on for hub and cube (the migration chain already gates them), and
# no saml-connection mount (paid edition, out of scope).
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: formbricks
services:
postgres:
image: pgvector/pgvector:0.8.6-pg18@sha256:691673308c99d2161ba298736f3147f1f22d79de2fb7ec93ae9b4afcab870b62
restart: unless-stopped
environment:
POSTGRES_DB: formbricks
POSTGRES_USER: formbricks
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/formbricks/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U formbricks -d formbricks"]
interval: 10s
retries: 30
redis:
image: valkey/valkey:9.0.5-alpine@sha256:0cb61366757e2bcd26500b4e8bb63cbd7117610e3e4f05aacb3c812511da7632
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- /srv/formbricks/redis:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 30
formbricks-migrate:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
command: ["node", "packages/database/dist/scripts/apply-migrations.js"]
depends_on:
postgres:
condition: service_healthy
hub-migrate:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
entrypoint: ["sh", "-c"]
command: ['/usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"']
depends_on:
formbricks-migrate:
condition: service_completed_successfully
hub:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
restart: unless-stopped
environment:
API_KEY: ${HUB_API_KEY}
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
depends_on:
hub-migrate:
condition: service_completed_successfully
cube:
image: cubejs/cube:v1.6.6@sha256:746a381c5deb1f33500c84bed357ebe68aa08acc5030939f9e9efd35796d368c
restart: unless-stopped
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: postgres
CUBEJS_DB_NAME: formbricks
CUBEJS_DB_USER: formbricks
CUBEJS_DB_PASS: ${DB_PASSWORD}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
CUBEJS_JWT_ISSUER: formbricks-web
CUBEJS_JWT_AUDIENCE: formbricks-cube
CUBEJS_DEFAULT_API_SCOPES: meta,data
CUBEJS_EXTERNAL_DEFAULT: "false"
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- /srv/formbricks/cube/cube.js:/cube/conf/cube.js:ro
- /srv/formbricks/cube/schema:/cube/conf/model:ro
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4000/readyz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
retries: 18
start_period: 40s
depends_on:
hub-migrate:
condition: service_completed_successfully
formbricks:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
restart: unless-stopped
env_file: /srv/formbricks/.env
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
REDIS_URL: redis://redis:6379
HUB_API_URL: http://hub:8080
CUBEJS_API_URL: http://cube:4000
EMAIL_VERIFICATION_DISABLED: "1"
PASSWORD_RESET_DISABLED: ${PASSWORD_RESET_DISABLED:-1}
SKIP_STARTUP_MIGRATION: "true"
ports:
- "127.0.0.1:8110:3000"
depends_on:
formbricks-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
cube:
condition: service_healthy
hub:
condition: service_started
EOF
cd /srv/formbricks && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/formbricks/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/formbricks/compose.yml` and paste again in one go. Nothing in this file is
optional. Upstream moved Hub and Cube into the baseline stack at version 5, and the web
container will not start without a Redis URL, so there is no smaller version of this that works.
## 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-formbricks
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Formbricks · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://formbricks.com/docs/self-hosting/configuration/domain-configuration
# and https://caddyserver.com/docs/automatic-https
<DOMAIN> {
# No X-Frame-Options and no frame-ancestors on purpose: link surveys are
# meant to be embedded in other people's pages.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8110 is the loopback port compose publishes. Not open in the firewall.
reverse_proxy 127.0.0.1:8110
}
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-formbricks /etc/caddy/Caddyfile`, reload,
and paste again. There is deliberately no `X-Frame-Options` in that block: link surveys are meant
to be embedded in other people's pages, and a deny would break the one distribution channel this
software exists for.
## 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 `8110`, `5432`, `6379`, `8080` or `4000`.
If you do not: delete anything for those five with `sudo ufw delete allow 8110`. 8110 is bound
to 127.0.0.1 by the compose file, and the database, the queue, Hub and Cube publish no host port
at all, so none of them has a port a firewall rule could apply to. 80/tcp is there to redirect
to HTTPS and answer the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3.
`Status: inactive` is a different problem: Prompt Zero left this firewall on, so something has
turned it off since, and `sudo ufw enable` puts it back before you go further.
## 7. Start and verify
The two migration jobs run and exit first, then Cube has to report healthy before the web
container is allowed to start. On a first pull the whole step takes several minutes, so let the
loop run.
```bash
cd /srv/formbricks
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/health
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -c 'Welcome to Formbricks'
docker compose ps -a
```
You should see, in order: the loop reaching `200`, then `{"status":"ok"}`, then
`https://<DOMAIN>/setup/intro`, then a count of at least `1`, then a table where
`formbricks-migrate` and `formbricks-hub-migrate` say `exited (0)` and the other five are up.
If you do not: a `formbricks-hub-migrate` that exited non-zero points back at step 3, where an
empty `DB_PASSWORD` makes every connection string wrong. A `cube` container stuck in `starting`
points at step 2, where a failed checksum leaves it with no model to serve. Read
`docker compose logs --tail 40 formbricks` and `docker compose logs --tail 20 cube hub-migrate`
before changing anything. Containers being up is not the same as the app answering.
Now open https://<DOMAIN> in a browser. The first screen carries the heading
`Welcome to Formbricks!` above a `Get started` button. Click it and create your administrator
account and organization. This is the only moment that account can be made: upstream keeps
signup closed on self-hosted instances, and afterwards only an owner or admin can invite anyone.
There is no SMTP server here, so there is no password-reset mail either. Put the email and
password in your password manager before you click anything else.
Then prove the door shut behind you:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup/intro
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/
```
You should see: `404`, then `https://<DOMAIN>/auth/login`.
If you do not: a `200` from the first command means the account was never created, so go back
and finish the wizard. The setup pages answer only while the user table is empty, which is what
makes this pair of commands worth running rather than trusting.
## 8. First backup and restore
Two artifacts. The database holds every survey, every response and every account. The config
archive holds what rebuilds the service around them, `ENCRYPTION_KEY` included.
```bash
cd /srv/formbricks
docker compose exec -T postgres pg_dump -U formbricks -d formbricks | gzip > /srv/formbricks/backups/formbricks-db-$(date +%F).sql.gz
sudo tar -czf /srv/formbricks/backups/formbricks-config-$(date +%F).tar.gz -C /srv/formbricks compose.yml .env cube -C /etc/caddy Caddyfile
ls -lh /srv/formbricks/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently. Valkey is not in the backup on purpose, it
holds cache and in-flight jobs rather than your data.
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. Run the dump line without `| gzip` to read the error.
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/formbricks
scp vps:/srv/formbricks/backups/* ~/backups/formbricks/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/formbricks/`.
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 an empty account:
```bash
cd /srv/formbricks
docker compose down
sudo rm -rf /srv/formbricks/postgres
sudo install -d -m 700 /srv/formbricks/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/formbricks/backups/formbricks-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U formbricks -d formbricks
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/health
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then `{"status":"ok"}`, and you
should be able to log in with the account you made in step 7.
If you do not: `role "formbricks" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the two files are
for before you skip this: the dump without the `.env` gives you rows you cannot decrypt, so they
are one backup in two pieces and they travel together.
## 9. Updating later
Releases are listed at https://github.com/formbricks/formbricks/releases, and the Hub and Cube
versions that pair with each one are in `charts/formbricks/values.yaml` inside that same tag.
Take both backup artifacts first, then edit the `image:` lines in /srv/formbricks/compose.yml to
the new tags and digests.
```bash
cd /srv/formbricks
docker compose pull
docker compose up -d
docker compose logs --tail 40 formbricks-migrate hub-migrate formbricks
```
You should see: both migration jobs exiting 0, then the app starting, and no repeating restart.
If you do not: put the old tags and digests back and run the same three commands. Then re-run
the health check from step 7 before you call the update done, because a stack that answers
`{"status":"ok"}` can still be failing if a migration stopped halfway.
## 10. What will probably go wrong
The wait. I brought this up on a 4 GB box, saw two containers in `Created` and one `starting`,
and went looking for what I had broken. Nothing was: the app is gated on Cube being healthy,
Cube has a 40 second start period before its first check counts, and is gated on both migration
jobs finishing. That chain ran past six minutes before /health answered. Let the loop in step 7
run all forty times before deciding it is broken.
## 11. Out of scope
- Do not configure SMTP. `EMAIL_VERIFICATION_DISABLED` and `PASSWORD_RESET_DISABLED` are 1,
upstream's default, and the survey loop needs no mail.
- Do not configure S3 or the bundled RustFS storage. That is a second subdomain and another
service; without it the file-upload and image questions stay off.
- Do not enable the `qwen` or `taxonomy` profiles, set `ENTERPRISE_LICENSE_KEY`, or configure
SSO, SAML or OIDC. The AI profiles are opt-in and one wants an NVIDIA GPU; the rest is the
paid edition, and this is the community one.376 lines · 16,481 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 Formbricks 5.3.0, with the PostgreSQL, Valkey, Hub and Cube it needs, under
~/selfhost/formbricks, answering at http://localhost:8110.
## 1. Preflight
Say this first: every link this makes begins with http://localhost:8110, which means "this
computer" wherever it is read, so one sent to anybody else resolves to nothing.
```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
distribution ID prints too, for step 2. This stack needs 4096 MB of RAM available and 20 GB
free, upstream's own Helm limits added up, and both architectures are published. On macOS and
Windows that number is the host's and Docker Desktop takes its share. Under either floor, print
both and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
Cube reads two files upstream ships in their repository, not their image. Fetch both at the
pinned tag and verify them first.
```bash
mkdir -p ~/selfhost/formbricks/backups ~/selfhost/formbricks/cube/schema
cd ~/selfhost/formbricks/cube
for f in cube.js schema/FeedbackRecords.js; do curl -fsSL -o "$f" "https://raw.githubusercontent.com/formbricks/formbricks/5.3.0/docker/cube/$f"; done
cat > SHA256SUMS <<'EOF'
723eea0f581200a686f854ff47b38f2e92bbfe5d802338049afaa061f154a335 cube.js
c3322a3739ee1cc57224139f502395a20dcbe4dd71e331be41d687ffdfe140f8 schema/FeedbackRecords.js
EOF
if command -v sha256sum >/dev/null 2>&1; then sha256sum -c SHA256SUMS; else shasum -a 256 -c SHA256SUMS; fi
ls -la ~/selfhost/formbricks
```
Assert: two lines ending `OK`; print both. macOS ships `shasum`, not `sha256sum`; that is what
the guard is for. On `FAILED`, stop: those are not the bytes recorded on 2026-08-06. `ls -la`
shows `backups` and `cube`, owned by the user.
## 4. Secrets
Six: the PostgreSQL password and five keys the app requires. Generate all six here, print none,
and keep them out of your summary and every log line. Git Bash ships openssl.
```bash
umask 077
cat > ~/selfhost/formbricks/.env <<EOF
WEBAPP_URL=http://localhost:8110
NEXTAUTH_URL=http://localhost:8110
DB_PASSWORD=$(openssl rand -hex 32)
NEXTAUTH_SECRET=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
CRON_SECRET=$(openssl rand -hex 32)
HUB_API_KEY=$(openssl rand -hex 32)
CUBEJS_API_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/formbricks/.env
umask 022
ls -l ~/selfhost/formbricks/.env
```
Assert: mode `-rw-------`. On Windows those bits are advisory and the real boundary is the
user's own account. `ENCRYPTION_KEY` encrypts two-factor secrets and single-use survey links, so
a database restored without it is unreadable.
## 5. compose.yml
```bash
cat > ~/selfhost/formbricks/compose.yml <<'EOF'
# Formbricks · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# compose setup ....... https://formbricks.com/docs/self-hosting/setup/docker
# variable reference .. https://formbricks.com/docs/self-hosting/configuration/environment-variables
#
# Seven services: five that stay up, two migration jobs that run in order and
# exit. Paths are relative to ~/selfhost/formbricks/. PostgreSQL and Valkey get
# named volumes rather than binds because each chowns its own data directory to
# a uid Docker Desktop cannot grant on a Windows home bind.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: formbricks
services:
postgres:
image: pgvector/pgvector:0.8.6-pg18@sha256:691673308c99d2161ba298736f3147f1f22d79de2fb7ec93ae9b4afcab870b62
restart: unless-stopped
environment:
POSTGRES_DB: formbricks
POSTGRES_USER: formbricks
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- formbricks-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U formbricks -d formbricks"]
interval: 10s
retries: 30
redis:
image: valkey/valkey:9.0.5-alpine@sha256:0cb61366757e2bcd26500b4e8bb63cbd7117610e3e4f05aacb3c812511da7632
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- formbricks-redisdata:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 30
formbricks-migrate:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
command: ["node", "packages/database/dist/scripts/apply-migrations.js"]
depends_on:
postgres:
condition: service_healthy
hub-migrate:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
entrypoint: ["sh", "-c"]
command: ['/usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"']
depends_on:
formbricks-migrate:
condition: service_completed_successfully
hub:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
restart: unless-stopped
environment:
API_KEY: ${HUB_API_KEY}
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
depends_on:
hub-migrate:
condition: service_completed_successfully
cube:
image: cubejs/cube:v1.6.6@sha256:746a381c5deb1f33500c84bed357ebe68aa08acc5030939f9e9efd35796d368c
restart: unless-stopped
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: postgres
CUBEJS_DB_NAME: formbricks
CUBEJS_DB_USER: formbricks
CUBEJS_DB_PASS: ${DB_PASSWORD}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
CUBEJS_JWT_ISSUER: formbricks-web
CUBEJS_JWT_AUDIENCE: formbricks-cube
CUBEJS_DEFAULT_API_SCOPES: meta,data
CUBEJS_EXTERNAL_DEFAULT: "false"
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- ./cube/cube.js:/cube/conf/cube.js:ro
- ./cube/schema:/cube/conf/model:ro
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4000/readyz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
retries: 18
start_period: 40s
depends_on:
hub-migrate:
condition: service_completed_successfully
formbricks:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
restart: unless-stopped
env_file: ./.env
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
REDIS_URL: redis://redis:6379
HUB_API_URL: http://hub:8080
CUBEJS_API_URL: http://cube:4000
EMAIL_VERIFICATION_DISABLED: "1"
PASSWORD_RESET_DISABLED: ${PASSWORD_RESET_DISABLED:-1}
SKIP_STARTUP_MIGRATION: "true"
ports:
- "127.0.0.1:8110:3000"
depends_on:
formbricks-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
cube:
condition: service_healthy
hub:
condition: service_started
volumes:
formbricks-pgdata:
formbricks-redisdata:
EOF
cd ~/selfhost/formbricks && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. Nothing here is optional: Hub and Cube are baseline in 5, and the app
needs a Redis URL to start.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve, and a certificate attests a public name that nothing here has; browsers treat
http://localhost as a secure context anyway, so pages needing crypto still work. 8110 binds to
127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on the internet. That
is the trade, and the point of this path. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/formbricks/compose.yml
```
Assert: one line, `- "127.0.0.1:8110:3000"`. The other four publish no host port at all.
## 7. Start and verify
The migration jobs run and exit first, then Cube must be healthy before the web container
starts. On a cold pull that takes minutes.
```bash
cd ~/selfhost/formbricks
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8110/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8110/health
curl -sSL -o /dev/null -w '%{url_effective}\n' http://localhost:8110/
curl -sSL http://localhost:8110/ | grep -c 'Welcome to Formbricks'
docker compose ps -a
```
Assert all five, printing each: the loop ends on `200`; health returns `{"status":"ok"}`; the
redirect lands on `http://localhost:8110/setup/intro`; the grep prints at least `1`, that screen
carrying the heading `Welcome to Formbricks!` above a `Get started` button; `ps -a` shows both
migration containers `exited (0)` and the other five up. If any misses, stop, run
`docker compose logs --tail 40 formbricks cube hub-migrate` and name the step to blame:
hub-migrate exiting non-zero is step 4 and an empty `DB_PASSWORD`; cube unhealthy is step 3 and
a failed checksum; `port is already allocated` is something else on 8110. A running container
is not success.
STOP: tell the user to open http://localhost:8110, click `Get started`, create their
administrator account and organization, and wait. Do not continue until they confirm. It is the
only moment that account can be made, signup closes afterwards, and with no SMTP there is no
reset mail, so have them save the password first.
Then prove the door shut:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8110/setup/intro
curl -sSL -o /dev/null -w '%{url_effective}\n' http://localhost:8110/
```
Assert: `404`, then `http://localhost:8110/auth/login`. The wizard answers only while the user
table is empty, and both must pass before you report success.
## 8. First backup and restore
The database holds every survey, response and account; the config archive holds what rebuilds
the service around it, `ENCRYPTION_KEY` included.
```bash
cd ~/selfhost/formbricks
docker compose exec -T postgres pg_dump -U formbricks -d formbricks | gzip > backups/formbricks-db-$(date +%F).sql.gz
tar -czf backups/formbricks-config-$(date +%F).tar.gz compose.yml .env cube
ls -lh backups/
```
Assert: both exist, both non-empty, print both sizes. Nothing stops: `pg_dump` snapshots a
running database consistently. Valkey holds cache and jobs, not data.
Both archives are on the same disk as the data, 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 both there with `cp`; in Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms both filenames are there, or say plainly that there is
no backup.
To restore: untar the config archive into ~/selfhost/formbricks first, so .env is back before
any container starts, because PostgreSQL reads `DB_PASSWORD` from it the moment it initialises
an empty volume. Then `docker compose down -v`, the one place `-v` belongs because it drops the
old volume on purpose, `docker compose up -d postgres`, wait 30 seconds, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T postgres psql -U formbricks -d formbricks`, then
`docker compose up -d`. Without that `.env` the rows come back unreadable.
## 9. Updating later
Releases are at https://github.com/formbricks/formbricks/releases; the Hub and Cube versions
paired with each are in `charts/formbricks/values.yaml` in that tag. Back up first, then edit
the image lines:
```bash
cd ~/selfhost/formbricks
docker compose pull
docker compose up -d
docker compose logs --tail 40 formbricks-migrate hub-migrate formbricks
```
Both jobs rerun on every start, so watch them exit 0, then re-run step 7's check.
## 10. What will probably go wrong
Memory. On my Mac the app container came up, died, came up and died again, and the log said
nothing useful, because the kernel had killed it rather than the process failing. Docker Desktop
runs everything in a virtual machine with its own memory ceiling, and the default is under what
five services want. Open its Settings, then Resources, give the machine 6 GB, apply, restart,
then `docker compose up -d` again.
## 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 point `WEBAPP_URL` at a LAN address or rebind 8110 to 0.0.0.0 so a phone can reach it.
- Do not configure SMTP, S3 or RustFS storage, enable the `qwen` or `taxonomy` profiles, or set
`ENTERPRISE_LICENSE_KEY`. Mail is off by upstream's default, storage is another service, an
AI profile wants a GPU, and the key is the paid tier.compose.local.ymlthe services, pinned · local layout122 lines
# Formbricks · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# compose setup ....... https://formbricks.com/docs/self-hosting/setup/docker
# variable reference .. https://formbricks.com/docs/self-hosting/configuration/environment-variables
#
# Seven services: five that stay up, two migration jobs that run in order and
# exit. Paths are relative to ~/selfhost/formbricks/. PostgreSQL and Valkey get
# named volumes rather than binds because each chowns its own data directory to
# a uid Docker Desktop cannot grant on a Windows home bind.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: formbricks
services:
postgres:
image: pgvector/pgvector:0.8.6-pg18@sha256:691673308c99d2161ba298736f3147f1f22d79de2fb7ec93ae9b4afcab870b62
restart: unless-stopped
environment:
POSTGRES_DB: formbricks
POSTGRES_USER: formbricks
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- formbricks-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U formbricks -d formbricks"]
interval: 10s
retries: 30
redis:
image: valkey/valkey:9.0.5-alpine@sha256:0cb61366757e2bcd26500b4e8bb63cbd7117610e3e4f05aacb3c812511da7632
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- formbricks-redisdata:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 30
formbricks-migrate:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
command: ["node", "packages/database/dist/scripts/apply-migrations.js"]
depends_on:
postgres:
condition: service_healthy
hub-migrate:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
entrypoint: ["sh", "-c"]
command: ['/usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"']
depends_on:
formbricks-migrate:
condition: service_completed_successfully
hub:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
restart: unless-stopped
environment:
API_KEY: ${HUB_API_KEY}
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
depends_on:
hub-migrate:
condition: service_completed_successfully
cube:
image: cubejs/cube:v1.6.6@sha256:746a381c5deb1f33500c84bed357ebe68aa08acc5030939f9e9efd35796d368c
restart: unless-stopped
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: postgres
CUBEJS_DB_NAME: formbricks
CUBEJS_DB_USER: formbricks
CUBEJS_DB_PASS: ${DB_PASSWORD}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
CUBEJS_JWT_ISSUER: formbricks-web
CUBEJS_JWT_AUDIENCE: formbricks-cube
CUBEJS_DEFAULT_API_SCOPES: meta,data
CUBEJS_EXTERNAL_DEFAULT: "false"
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- ./cube/cube.js:/cube/conf/cube.js:ro
- ./cube/schema:/cube/conf/model:ro
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4000/readyz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
retries: 18
start_period: 40s
depends_on:
hub-migrate:
condition: service_completed_successfully
formbricks:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
restart: unless-stopped
env_file: ./.env
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
REDIS_URL: redis://redis:6379
HUB_API_URL: http://hub:8080
CUBEJS_API_URL: http://cube:4000
EMAIL_VERIFICATION_DISABLED: "1"
PASSWORD_RESET_DISABLED: ${PASSWORD_RESET_DISABLED:-1}
SKIP_STARTUP_MIGRATION: "true"
ports:
- "127.0.0.1:8110:3000"
depends_on:
formbricks-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
cube:
condition: service_healthy
hub:
condition: service_started
volumes:
formbricks-pgdata:
formbricks-redisdata:agent-readable mirror: /self-host/typeform.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, pinned123 lines
# Formbricks · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# compose setup ....... https://formbricks.com/docs/self-hosting/setup/docker
# variable reference .. https://formbricks.com/docs/self-hosting/configuration/environment-variables
#
# Seven services: five that stay up, two migration jobs that run in order and
# exit. Upstream makes Hub, Cube and Valkey mandatory in version 5. Only 8110
# is published, on loopback. Digests read 2026-08-06, all five multi-arch.
#
# Three deliberate trims from upstream's compose: no validate-env prefix on
# the migrate job (the web container runs it at startup), no direct postgres
# depends_on for hub and cube (the migration chain already gates them), and
# no saml-connection mount (paid edition, out of scope).
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
name: formbricks
services:
postgres:
image: pgvector/pgvector:0.8.6-pg18@sha256:691673308c99d2161ba298736f3147f1f22d79de2fb7ec93ae9b4afcab870b62
restart: unless-stopped
environment:
POSTGRES_DB: formbricks
POSTGRES_USER: formbricks
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/formbricks/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U formbricks -d formbricks"]
interval: 10s
retries: 30
redis:
image: valkey/valkey:9.0.5-alpine@sha256:0cb61366757e2bcd26500b4e8bb63cbd7117610e3e4f05aacb3c812511da7632
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- /srv/formbricks/redis:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 30
formbricks-migrate:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
command: ["node", "packages/database/dist/scripts/apply-migrations.js"]
depends_on:
postgres:
condition: service_healthy
hub-migrate:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
entrypoint: ["sh", "-c"]
command: ['/usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"']
depends_on:
formbricks-migrate:
condition: service_completed_successfully
hub:
image: ghcr.io/formbricks/hub:0.8.3@sha256:4dc0c4f26cf999b3bf4a26d7b09634fc65ae23cbb30c9ad82042da019d231458
restart: unless-stopped
environment:
API_KEY: ${HUB_API_KEY}
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?sslmode=disable
depends_on:
hub-migrate:
condition: service_completed_successfully
cube:
image: cubejs/cube:v1.6.6@sha256:746a381c5deb1f33500c84bed357ebe68aa08acc5030939f9e9efd35796d368c
restart: unless-stopped
environment:
CUBEJS_DB_TYPE: postgres
CUBEJS_DB_HOST: postgres
CUBEJS_DB_NAME: formbricks
CUBEJS_DB_USER: formbricks
CUBEJS_DB_PASS: ${DB_PASSWORD}
CUBEJS_API_SECRET: ${CUBEJS_API_SECRET}
CUBEJS_JWT_ISSUER: formbricks-web
CUBEJS_JWT_AUDIENCE: formbricks-cube
CUBEJS_DEFAULT_API_SCOPES: meta,data
CUBEJS_EXTERNAL_DEFAULT: "false"
CUBEJS_CACHE_AND_QUEUE_DRIVER: memory
volumes:
- /srv/formbricks/cube/cube.js:/cube/conf/cube.js:ro
- /srv/formbricks/cube/schema:/cube/conf/model:ro
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4000/readyz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
retries: 18
start_period: 40s
depends_on:
hub-migrate:
condition: service_completed_successfully
formbricks:
image: ghcr.io/formbricks/formbricks:5.3.0@sha256:d79dba3668a359b63d984ac39b19a58fb6746b3aed57fd890b9f2f6f372210e6
restart: unless-stopped
env_file: /srv/formbricks/.env
environment:
DATABASE_URL: postgresql://formbricks:${DB_PASSWORD}@postgres:5432/formbricks?schema=public
REDIS_URL: redis://redis:6379
HUB_API_URL: http://hub:8080
CUBEJS_API_URL: http://cube:4000
EMAIL_VERIFICATION_DISABLED: "1"
PASSWORD_RESET_DISABLED: ${PASSWORD_RESET_DISABLED:-1}
SKIP_STARTUP_MIGRATION: "true"
ports:
- "127.0.0.1:8110:3000"
depends_on:
formbricks-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
cube:
condition: service_healthy
hub:
condition: service_startedCaddyfilethe hostname and TLS21 lines
# Formbricks · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://formbricks.com/docs/self-hosting/configuration/domain-configuration
# and https://caddyserver.com/docs/automatic-https
<DOMAIN> {
# No X-Frame-Options and no frame-ancestors on purpose: link surveys are
# meant to be embedded in other people's pages.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8110 is the loopback port compose publishes. Not open in the firewall.
reverse_proxy 127.0.0.1:8110
}install.shthe same install, no agent179 lines
#!/usr/bin/env bash
# Formbricks · 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=surveys.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://formbricks.com/docs/self-hosting/setup/docker
# https://formbricks.com/docs/self-hosting/configuration/environment-variables
# https://formbricks.com/docs/self-hosting/auth-behavior
# https://github.com/formbricks/formbricks/blob/5.3.0/charts/formbricks/values.yaml
#
# Six secrets are generated here, on this machine: the PostgreSQL password and
# the five keys the application requires. All six go into /srv/formbricks/.env
# with mode 600 and none is ever printed. ENCRYPTION_KEY is the one that
# matters most: upstream uses it for two-factor secrets, single-use survey
# links and audit-log hashing, so a database restored without that file is a
# database nobody can fully read.
#
# DOMAIN_HOST is also WEBAPP_URL, the front of every survey link you send.
# Choose it once. Changing it later breaks links already in other inboxes.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/formbricks}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
TAG="5.3.0"
RAW="https://raw.githubusercontent.com/formbricks/formbricks/${TAG}/docker/cube"
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. surveys.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"
command -v sha256sum >/dev/null 2>&1 || die "sha256sum is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; five services 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}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out, and verify the two Cube config files --------------
#
# Cube reads a config file and a data model that upstream ships in their
# repository rather than in their image. Both are fetched at the pinned tag and
# checked against digests read on 2026-08-06 before anything mounts them.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/cube" "$APP_DIR/cube/schema"
sudo install -d -m 700 "$APP_DIR/postgres" "$APP_DIR/redis"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
cd "$APP_DIR/cube"
for f in cube.js schema/FeedbackRecords.js; do
curl -fsSL -o "$f" "${RAW}/${f}"
done
cat > SHA256SUMS <<-'SUMS'
723eea0f581200a686f854ff47b38f2e92bbfe5d802338049afaa061f154a335 cube.js
c3322a3739ee1cc57224139f502395a20dcbe4dd71e331be41d687ffdfe140f8 schema/FeedbackRecords.js
SUMS
sha256sum -c SHA256SUMS || die "a Cube config file does not match its recorded digest. Stop and investigate."
# --- 3. Generate the six secrets, on the server ------------------------------
#
# Hex, because upstream caps NEXTAUTH_SECRET, ENCRYPTION_KEY and CRON_SECRET at
# 32 bytes. Read them later with
# sudo grep ENCRYPTION_KEY /srv/formbricks/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
WEBAPP_URL=https://${DOMAIN_HOST}
NEXTAUTH_URL=https://${DOMAIN_HOST}
DB_PASSWORD=$(openssl rand -hex 32)
NEXTAUTH_SECRET=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
CRON_SECRET=$(openssl rand -hex 32)
HUB_API_KEY=$(openssl rand -hex 32)
CUBEJS_API_SECRET=$(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-formbricks"
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 none of the five app ports is one of them -------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8110, 5432, 6379, 8080 and 4000 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The two migration jobs run and exit first, then Cube has to report healthy
# before the web container is allowed to start. On a cold pull that chain takes
# several minutes, which is what the loop below is for.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
[ "$code" = "200" ] && break
sleep 15
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 formbricks cube hub-migrate"
curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"status":"ok"' \
|| die "/health answered 200 without status ok. Check: docker compose logs --tail 40 formbricks"
# A fresh instance sends the root to the setup wizard. Both of these are the
# same assert from two directions: the redirect target, and the heading.
landing="$(curl -sSL -o /dev/null -w '%{url_effective}' "https://${DOMAIN_HOST}/" || true)"
[ "$landing" = "https://${DOMAIN_HOST}/setup/intro" ] \
|| die "https://${DOMAIN_HOST}/ landed on ${landing}, not the setup wizard. Stop and investigate."
curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'Welcome to Formbricks' \
|| die "the first screen does not carry 'Welcome to Formbricks'. Check: docker compose logs --tail 40 formbricks"
docker compose ps -a
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U formbricks -d formbricks | gzip > "$APP_DIR/backups/formbricks-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/formbricks-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env cube -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/formbricks-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/formbricks-config-${STAMP}.tar.gz" ] || die "the config archive is empty"
cat <<-DONE
Formbricks is answering at https://${DOMAIN_HOST}/health
1. Open https://${DOMAIN_HOST} now. The first screen says
"Welcome to Formbricks!" with a Get started button. Create your
administrator account and organization there. That is the only moment
it can be made: signup stays closed on self-hosted instances, and
afterwards only an owner or admin can invite anyone.
2. There is no SMTP server here, so there is no password-reset mail.
Put that email and password in your password manager first.
3. Once the account exists, confirm the door shut behind you:
curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/setup/intro
It should print 404. The setup pages answer only while the user table
is empty.
4. Your six secrets are in $APP_DIR/.env, mode 600, and none was printed
here. ENCRYPTION_KEY is the one to keep: a database dump restored
without it comes back undecryptable.
5. First backup written to $APP_DIR/backups: a database dump and a config
archive. They are on the same disk as the data, which is not a backup.
Copy both somewhere else tonight, and keep them together.
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 Typeform.
- This is a stack, not an app. Version 5 runs five services that stay up plus two migration jobs, and upstream makes Hub, Cube and Valkey mandatory rather than optional. On any given morning one of the five can be the unhappy one, and you are the only person who will notice.
- The licence has a carve-out. Everything outside apps/web/modules/ee is AGPLv3 and the JavaScript, mobile and API packages are MIT, but the ee directory is under a separate commercial licence, and that is where the paid edition's features live: SSO and SAML, teams and roles, audit logging, dashboards, white-labelling. The community edition you install here has no response cap and no user cap, and stops short of that list.
- One key stands between you and readable data. ENCRYPTION_KEY encrypts two-factor secrets and single-use survey links and hashes the audit log, so a database dump restored without the .env it lives in comes back with rows nobody can read. The dump and the config archive are one backup in two files.
- No mail, by design and by upstream's default. This install sets EMAIL_VERIFICATION_DISABLED and PASSWORD_RESET_DISABLED, which is what lets it run with no SMTP server. It also means the one administrator account is the whole recovery story: lose that password and the way back in is editing the database.
- No file uploads. Image questions, file-upload questions and workspace logos need S3-compatible storage on a second hostname, and this install configures none, so upstream switches those features off.
Where this came from
“Starting with Formbricks v5, the production Docker Compose stack includes Formbricks Hub and Cube as part of the baseline.”
- The baseline self-hosted stack for version 5 is five long-running services, Formbricks Web, PostgreSQL, Redis/Valkey, Formbricks Hub and Cube, plus two migration jobs that run in order and exit. source
- WEBAPP_URL, NEXTAUTH_URL, DATABASE_URL, NEXTAUTH_SECRET, ENCRYPTION_KEY, CRON_SECRET, REDIS_URL, HUB_API_KEY, HUB_API_URL, CUBEJS_API_URL and CUBEJS_API_SECRET are all marked required, and the application will not start without a Redis URL. source
- Upstream's own compose file sets EMAIL_VERIFICATION_DISABLED and PASSWORD_RESET_DISABLED to 1, which is what lets a self-hosted install run with no SMTP server configured. source
- Self-hosted instances have user signup disabled by default, and only organization owners or admins can invite new users afterwards. source
- The Hub image that pairs with this release is pinned by digest in upstream's Helm chart, alongside the Cube tag the same chart uses. source
- The two Cube configuration files fetched at tag 5.3.0 hashed, when read on 2026-08-06, to 723eea0f581200a686f854ff47b38f2e92bbfe5d802338049afaa061f154a335 (cube.js) and c3322a3739ee1cc57224139f502395a20dcbe4dd71e331be41d687ffdfe140f8 (schema/FeedbackRecords.js), the digests every install verifies with sha256sum -c before mounting either file. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Typeform?
Not Typeform 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 Formbricks. Link surveys and in-product feedback on your own domain, with no monthly response cap deciding your bill. 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 240 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 Typeform?
Formbricks. Link surveys and in-product feedback on your own domain, with no monthly response cap deciding your bill. The closest match to what people actually buy Typeform for: one-question-at-a-time link surveys, a builder with logic and hidden fields, and an in-app widget for surveying people who are already using your product. Self-hosted it has no response cap at all, which removes the meter that decides your Typeform bill. The price is the install: version 5 is a seven-service stack, and Hub and Cube are not optional parts of it. Formbricks 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 Typeform?
4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Formbricks itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Typeform Business, $129/mo — $1,548 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 Formbricks install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Formbricks 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 Formbricks 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 survey link this makes begins with http://localhost:8110, which resolves on this computer and nowhere else, so the people you wanted to answer the survey get a connection error instead of your first question. 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-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.