OpnForm logo

OpnForm

Beautiful open-source form builder

Alternative to: typeform, google forms, jotform, tally


OpnForm is an open-source form builder for creating beautiful forms and surveys without writing code. It supports conditional logic, file uploads, and integrations, and gives self-hosters full ownership of their form data instead of relying on a hosted SaaS provider.

OpnForm Docker Compose example

Self-host OpnForm on your own server, homelab, or VPS starting from this Docker Compose example. It runs OpnForm in Docker containers using the official jhumanj/opnform-api:latest, jhumanj/opnform-api:latest, jhumanj/opnform-api:latest, jhumanj/opnform-client:latest, redis:7, postgres:16, nginx:1 images, with persistent volumes and automatic restarts preconfigured. Review the environment variables and adjust them to your setup, save the file as compose.yml (or docker-compose.yml), and start the stack with docker compose up -d.

services:
  # --- OpnForm API (Laravel/PHP-FPM). The image entrypoint waits for the
  # database, runs migrations, generates the Passport OAuth keys and
  # optimizes the app automatically on first boot — no manual setup step.
  api:
    image: jhumanj/opnform-api:latest
    container_name: opnform-api
    restart: unless-stopped
    volumes:
      - opnform_storage:/usr/share/nginx/html/storage:rw
    environment:
      APP_NAME: "OpnForm"
      APP_ENV: production
      # Laravel application key — unique per instance, encrypts sessions and
      # cookies. Must be a valid "base64:<32-byte>" value; change it.
      APP_KEY: "base64:Keqi37qKvDQF9BLfEqXKz14sgOSIzJAKRN0qT+qoyUI="
      APP_DEBUG: "false"
      # Public URL where your OpnForm instance is reachable
      APP_URL: "http://localhost"
      LOG_CHANNEL: errorlog
      LOG_LEVEL: error
      # Database — internal Postgres service (not exposed outside the stack)
      DB_CONNECTION: pgsql
      DB_HOST: db
      DB_PORT: "5432"
      DB_DATABASE: forge
      DB_USERNAME: forge
      DB_PASSWORD: forge
      # Redis — internal cache / queue / session backend
      REDIS_HOST: redis
      CACHE_DRIVER: redis
      QUEUE_CONNECTION: redis
      SESSION_DRIVER: redis
      FILESYSTEM_DRIVER: local
      LOCAL_FILESYSTEM_VISIBILITY: public
      # Shared secret for privileged server-to-server calls from the frontend
      # to the API — must match the client's NUXT_API_SECRET; change it.
      FRONT_API_SECRET: "vPYBywULqKzAwhKP32acB9agmd0Fvx2PdpIWpr8d"
      # JWT signing secret — unique per instance; change it.
      JWT_SECRET: "joyU5RMjlKtWJ8xnIlZ02xNvIwq4PWn6h6IQlNHc"
      JWT_TTL: "1440"
      JWT_SKIP_IP_UA_VALIDATION: "false"
      # Mail — defaults to writing emails to the log; set real SMTP to deliver
      MAIL_MAILER: log
      MAIL_FROM_ADDRESS: "hello@example.com"
      MAIL_FROM_NAME: OpnForm
      # PHP runtime limits
      PHP_MEMORY_LIMIT: "1G"
      PHP_MAX_EXECUTION_TIME: "600"
      PHP_UPLOAD_MAX_FILESIZE: "64M"
      PHP_POST_MAX_SIZE: "64M"
      SHOW_OFFICIAL_TEMPLATES: "true"
    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: 3
      start_period: 60s

  # --- Queue worker (processes background jobs: emails, integrations, exports)
  api-worker:
    image: jhumanj/opnform-api:latest
    container_name: opnform-api-worker
    restart: unless-stopped
    command: ["php", "artisan", "queue:work"]
    volumes:
      - opnform_storage:/usr/share/nginx/html/storage:rw
    environment:
      APP_NAME: "OpnForm"
      APP_ENV: production
      # Must match the API's APP_KEY
      APP_KEY: "base64:Keqi37qKvDQF9BLfEqXKz14sgOSIzJAKRN0qT+qoyUI="
      APP_DEBUG: "false"
      APP_URL: "http://localhost"
      LOG_CHANNEL: errorlog
      LOG_LEVEL: error
      DB_CONNECTION: pgsql
      DB_HOST: db
      DB_PORT: "5432"
      DB_DATABASE: forge
      DB_USERNAME: forge
      DB_PASSWORD: forge
      REDIS_HOST: redis
      CACHE_DRIVER: redis
      QUEUE_CONNECTION: redis
      SESSION_DRIVER: redis
      FILESYSTEM_DRIVER: local
      LOCAL_FILESYSTEM_VISIBILITY: public
      # Must match the API's FRONT_API_SECRET
      FRONT_API_SECRET: "vPYBywULqKzAwhKP32acB9agmd0Fvx2PdpIWpr8d"
      # Must match the API's JWT_SECRET
      JWT_SECRET: "joyU5RMjlKtWJ8xnIlZ02xNvIwq4PWn6h6IQlNHc"
      JWT_TTL: "1440"
      JWT_SKIP_IP_UA_VALIDATION: "false"
      MAIL_MAILER: log
      MAIL_FROM_ADDRESS: "hello@example.com"
      MAIL_FROM_NAME: OpnForm
      PHP_MEMORY_LIMIT: "1G"
      PHP_MAX_EXECUTION_TIME: "600"
      PHP_UPLOAD_MAX_FILESIZE: "64M"
      PHP_POST_MAX_SIZE: "64M"
      SHOW_OFFICIAL_TEMPLATES: "true"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  # --- Scheduler (runs Laravel's scheduled tasks: cleanup, telemetry, etc.)
  api-scheduler:
    image: jhumanj/opnform-api:latest
    container_name: opnform-api-scheduler
    restart: unless-stopped
    command: ["php", "artisan", "schedule:work"]
    volumes:
      - opnform_storage:/usr/share/nginx/html/storage:rw
    environment:
      APP_NAME: "OpnForm"
      APP_ENV: production
      # Must match the API's APP_KEY
      APP_KEY: "base64:Keqi37qKvDQF9BLfEqXKz14sgOSIzJAKRN0qT+qoyUI="
      APP_DEBUG: "false"
      APP_URL: "http://localhost"
      LOG_CHANNEL: errorlog
      LOG_LEVEL: error
      DB_CONNECTION: pgsql
      DB_HOST: db
      DB_PORT: "5432"
      DB_DATABASE: forge
      DB_USERNAME: forge
      DB_PASSWORD: forge
      REDIS_HOST: redis
      CACHE_DRIVER: redis
      QUEUE_CONNECTION: redis
      SESSION_DRIVER: redis
      FILESYSTEM_DRIVER: local
      LOCAL_FILESYSTEM_VISIBILITY: public
      # Must match the API's FRONT_API_SECRET
      FRONT_API_SECRET: "vPYBywULqKzAwhKP32acB9agmd0Fvx2PdpIWpr8d"
      # Must match the API's JWT_SECRET
      JWT_SECRET: "joyU5RMjlKtWJ8xnIlZ02xNvIwq4PWn6h6IQlNHc"
      JWT_TTL: "1440"
      JWT_SKIP_IP_UA_VALIDATION: "false"
      MAIL_MAILER: log
      MAIL_FROM_ADDRESS: "hello@example.com"
      MAIL_FROM_NAME: OpnForm
      PHP_MEMORY_LIMIT: "1G"
      PHP_MAX_EXECUTION_TIME: "600"
      PHP_UPLOAD_MAX_FILESIZE: "64M"
      PHP_POST_MAX_SIZE: "64M"
      SHOW_OFFICIAL_TEMPLATES: "true"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  # --- Frontend (Nuxt). Uses relative API paths so it lives behind the single
  # ingress origin; server-side calls reach the API back through the ingress.
  ui:
    image: jhumanj/opnform-client:latest
    container_name: opnform-client
    restart: unless-stopped
    environment:
      NUXT_PUBLIC_APP_URL: "/"
      NUXT_PUBLIC_API_BASE: "/api"
      NUXT_PRIVATE_API_BASE: "http://ingress/api"
      NUXT_PUBLIC_ENV: production
      # Must match the API's FRONT_API_SECRET
      NUXT_API_SECRET: "vPYBywULqKzAwhKP32acB9agmd0Fvx2PdpIWpr8d"
      NUXT_PUBLIC_H_CAPTCHA_SITE_KEY: ""
      NUXT_PUBLIC_RE_CAPTCHA_SITE_KEY: ""
      NUXT_PUBLIC_ROOT_REDIRECT_URL: ""
    depends_on:
      api:
        condition: service_healthy

  # --- Redis (cache, queue and session store)
  redis:
    image: redis:7
    container_name: opnform-redis
    restart: unless-stopped
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      interval: 30s
      timeout: 5s
      retries: 3

  # --- PostgreSQL database
  db:
    image: postgres:16
    container_name: opnform-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: forge
      POSTGRES_USER: forge
      POSTGRES_PASSWORD: forge
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U forge -d forge"]
      interval: 30s
      timeout: 5s
      retries: 5

  # --- Ingress (nginx). The single entrypoint: proxies the frontend and routes
  # /api, /open, /forms/assets to the PHP API. Templated with NGINX_MAX_BODY_SIZE.
  ingress:
    image: nginx:1
    container_name: opnform-ingress
    restart: unless-stopped
    volumes:
      - ./nginx.conf:/etc/nginx/templates/default.conf.template
    environment:
      NGINX_MAX_BODY_SIZE: "64m"
    depends_on:
      api:
        condition: service_started
      ui:
        condition: service_started

volumes:
  postgres-data:
  opnform_storage:
  redis-data:

Extra files

The Docker Compose configuration above bind-mounts the following file. Save it next to your compose.yml, keeping the same relative path.

nginx.conf

map $request_uri $api_uri {
    ~^/api(/.*$) $1;
    default $request_uri;
}

server {
    listen 80;
    server_name opnform;
    root /usr/share/nginx/html/public;
    client_max_body_size ${NGINX_MAX_BODY_SIZE};

    access_log /dev/stdout;
    error_log /dev/stderr error;

    index index.html index.htm index.php;

    location / {
        proxy_http_version 1.1;
        proxy_pass http://opnform-client:3000;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
    }

    location ~/(api|open|local\/temp|forms\/assets)/ {
        set $original_uri $uri;
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass opnform-api:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root/index.php;
        fastcgi_param REQUEST_URI $api_uri;
        # Laravel accepts these headers only from IPs listed in TRUSTED_PROXIES.
        # This preserves the client IP behind an explicitly trusted reverse proxy.
        fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
        fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
        fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port;
        fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
    }

    # Deny access to . files
    location ~ /\. {
        deny all;
    }
}

Prefer a managed setup? WinterFlow installs, configures, and updates OpnForm for you using this same Docker Compose configuration.