Skip to content

OpnForm

OpnForm builds forms with a drag-and-drop editor, conditional logic, file uploads and webhooks. Seven containers — a Laravel API with its worker and scheduler, a Nuxt frontend, nginx in front of both, PostgreSQL and Redis.

Last run on a host 8 Nov 2026 · data restored from a backup

Pinned at 2.8.4 (released 5 Nov 2026) · the newest upstream release

In the upstream images: 2 CRITICAL · 34 HIGH with a published fix — per image

To try it: Docker, nothing else. See Try it locally.

To deploy it behind the proxy: Traefik running, a domain, and SMTP if forms should notify anyone.

On your own machine, with no proxy, domain or certificate. From apps/opnform/:

Terminal window
cp .env.local.example .env.local
# The secrets ship empty; generate the ones this stack needs
{
echo "DB_PASSWORD=$(openssl rand -hex 32)"
echo "APP_KEY=base64:$(openssl rand -base64 32)"
echo "JWT_SECRET=$(openssl rand -base64 32 | tr -d '\n')"
echo "FRONT_API_SECRET=$(openssl rand -base64 32 | tr -d '\n')"
} >> .env.local
mkdir -p volumes/local/redis
sudo chown 999:1000 volumes/local/redis
docker compose -f docker-compose.local.yml --env-file .env.local up -d

Then open http://localhost:8080. Stop it again with the same command and down.

Traefik routes to nginx alone. Everything else is reached through it — /api/* goes to the Laravel backend, the rest to the frontend.

From apps/opnform/:

  1. Copy the environment file and set APP_TRAEFIK_HOST, TZ and MAIL_FROM_ADDRESS:

    Terminal window
    cp .env.example .env
  2. Generate the three application secrets and paste each into .env:

    Terminal window
    echo "base64:$(openssl rand -base64 32)" # APP_KEY
    openssl rand -base64 32 # JWT_SECRET
    openssl rand -base64 32 # FRONT_API_SECRET

    Laravel has no _FILE form for these, so they live in .env rather than in .secrets/.

  3. Generate the database password and mirror it into .env:

    Terminal window
    mkdir -p .secrets
    openssl rand -base64 32 | tr -d '\n' > .secrets/db_pwd.txt
    sed -i "s|^DB_PWD_INLINE=.*|DB_PWD_INLINE=$(cat .secrets/db_pwd.txt)|" .env
  4. Create the volumes. The API entrypoint creates the subdirectories and sets their ownership on first start:

    Terminal window
    mkdir -p volumes/storage volumes/postgres volumes/redis
    sudo chown 999:1000 volumes/redis # Redis cannot fix this ownership itself
  5. Start it and watch all seven come up:

    Terminal window
    docker compose up -d
    docker compose logs -f

    Wait for ready to handle connections on the API, then Listening on from the frontend.

  6. Over the VPN, open https://<APP_TRAEFIK_HOST> and create the first account — it becomes the workspace admin, with no setup secret, which is why the route ships closed (access-vpn). Later signups are refused (SELF_HOSTED=true). Then set APP_TRAEFIK_ACCESS=access-public in .env and run docker compose up -d, so the forms reach their respondents.

Terminal window
docker compose ps # seven services

Then build one form, submit it with a file attached, and confirm both the answer and the file are there. That covers the frontend, the API, the queue worker and the storage mount — the parts that fail independently of each other.

NGINX_MAX_BODY_SIZE in .env caps what a respondent can attach, at 64 MB by default. nginx rejects anything larger before the API sees it, so raising it in the application alone has no effect.

Notifications and webhooks run through the queue worker; scheduled tasks through the scheduler. Both are separate containers using the same image as the API. A form that saves answers but sends no notifications is usually the worker rather than the mail configuration.

Laravel’s own commands run in the api service; the queue is worked by api-worker, not by hand:

Terminal window
docker compose exec api php artisan cache:clear # application cache
docker compose exec api php artisan config:clear # configuration cache
Database PostgreSQL, container opnform-db, database opnform, user opnform
Password .secrets/db_pwd.txt
State volumes/postgres · volumes/storage (files submitted through forms)
Rebuildable volumes/redis
Quiescing Not needed — the dump is consistent on its own.

For borgmatic:

postgresql_databases:
- name: opnform
username: opnform
# The server's own client, inside its container
pg_dump_command: docker exec opnform-db pg_dump
pg_restore_command: docker exec -i opnform-db pg_restore
psql_command: docker exec -i opnform-db psql

Answers are database rows; anything a respondent uploaded is in volumes/storage. A database-only restore gives submissions whose attachments are gone — and unlike most losses, that data cannot be regenerated without asking the people who sent it.

Restore order: database, then the application. The worker and scheduler come up with it; a queue restored mid-flight retries.

  1. Read the release notes. The step from 1.x to 2.x is a major change.

  2. Back up the database and volumes/storage together.

  3. Raise APP_TAG in .env, then:

    Terminal window
    docker compose pull && docker compose up -d
    docker compose logs -f
  4. Open one existing form in the editor and submit it. A migration that changed the form schema surfaces there.

404s for /api/_nuxt_icon/… in the browser console. The icon proxy path is routed to the Laravel API instead of the frontend’s icon server. Upstream issue, cosmetic — the application works.

Uploads fail with a permissions error. The entrypoint sets ownership on volumes/storage at first start. If it could not, correct the ownership on the host and restart the API container.

A form saves answers but sends nothing. Check the queue worker container before the mail settings.