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.