# Can I self-host Tally?

**YES, IF** — it's called OpnForm. ONGOING OPS setup · ~5 hours to running · 4 GB RAM minimum · $24/mo you stop paying ($288/yr on the Pro plan).

OpnForm authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/tally/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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:
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

## Also evaluated

Ranked below OpnForm for this swap. The prompts above install OpnForm only.

- **HeyForm** — 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.
- **Formbricks** — 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 page this mirrors: https://caniselfhostit.com/self-host/tally/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
