Compare commits

...

5 commits

Author SHA1 Message Date
a6f301ee54 WIP on OpnForm 2026-05-20 12:48:48 +02:00
6de9c031c7
fix: (homarr) removed mistakenly added variable 2026-05-19 11:26:18 +02:00
2341815daf feat(opnform)!: add admin and OIDC bootstrap, rename role to lowercase
Rename roles/OpnForm → roles/opnform so the role resolves as
  digitalboard.core.opnform (Ansible collection convention is
  lowercase). Update tests/test.yml reference accordingly.

  Add automated admin user creation via POST /api/register, gated on
  opnform_admin_email + opnform_admin_password. Idempotent through a
  prior login probe. Without these vars the manual setup page flow is
  preserved.

  Add automated OIDC IdentityConnection setup via the per-workspace
  /api/open/workspaces/{id}/oidc-connections endpoint, gated on
  opnform_oidc_enabled. Hard-coupled to the admin bootstrap (the API
  requires an authenticated admin token); validation block fails fast
  if OIDC is enabled without admin credentials. Supports both an
  explicit opnform_oidc_group_role_mappings list and a fallback
  opnform_oidc_admin_group convenience var.

  Convert opnform_oidc_scopes from space-separated string to YAML list
  to match OpnForm's API expectation. Rewrite README "First login" and
  "OIDC setup" sections to reflect that self-hosted OpnForm does not
  ship a pre-seeded admin and to document the new bootstrap paths.
  BREAKING CHANGE: opnform_oidc_scopes changed from space-separated
  string to YAML list. Inventories that override it must update from
  "openid profile email" to [openid, profile, email].
2026-05-18 22:40:19 +02:00
3f90843f97 fix: added pycache to gitignore 2026-05-18 21:00:20 +02:00
6c1c40668d
chore: add new role for OpnForm 2026-05-13 17:23:34 +02:00
13 changed files with 956 additions and 1 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
/.idea/
plugins/lookup/__pycache__/

View file

@ -1,4 +1,3 @@
homarr_apps: [ ]
#SPDX-License-Identifier: MIT-0
---
# defaults file for homarr

169
roles/opnform/README.md Normal file
View file

@ -0,0 +1,169 @@
# opnform
Deploy [OpnForm](https://github.com/OpnForm/OpnForm) as a self-contained
Docker Compose stack behind Traefik.
## What this role does
- Deploys the full official OpnForm stack: `api`, `api-worker`, `api-scheduler`,
`ui`, `db` (Postgres), `redis`, and `ingress` (nginx)
- Configures all environment variables for self-hosted production use
- Integrates the ingress container with an existing Traefik proxy network
- Waits for the API container to become healthy before returning
## What this role does NOT do (stage 1)
- Does not pre-configure OIDC / identity_connections — set up via Admin UI
## Architecture note: why two reverse proxies?
```
Browser → Traefik (TLS, host routing) → ingress-nginx → api (PHP-FPM) / ui (Nuxt)
```
The `ingress` container looks like a redundant proxy next to Traefik but
does a different job. OpnForm's `api` image is **PHP-FPM only** — it
speaks the FastCGI protocol on port 9000, not HTTP. Traefik cannot
translate FastCGI, so the ingress nginx is required to:
- Translate HTTP `/api/*` requests into FastCGI calls to `api:9000`
- Rewrite request URIs via the `$api_uri` map
- Set Laravel-specific FastCGI params (`SCRIPT_FILENAME`, `REQUEST_URI`)
- Reverse-proxy `/` to the Nuxt UI container on port 3000
Both containers run on the same Docker network on the same host, so the
performance overhead of the extra hop is negligible (in-kernel memory
copy, not a real network round-trip). Removing the ingress would require
a custom OpnForm image with a built-in HTTP server, which is out of
scope for this role.
## Required variables
Provide via OpenBao, Ansible Vault, or extra-vars. **Never commit real
secrets to version control.**
| Variable | Format | Generate with |
|---|---|---|
| `opnform_app_key` | `base64:<32 bytes base64>` | `echo "base64:$(openssl rand -base64 32)"` |
| `opnform_jwt_secret` | 32 bytes base64 | `openssl rand -base64 32` |
| `opnform_front_api_secret` | 32 bytes base64 | `openssl rand -base64 32` |
| `opnform_db_password` | strong password | `openssl rand -base64 24` |
When `opnform_oidc_enabled` is `true`:
| Variable | Source |
|---|---|
| `opnform_oidc_client_secret` | from your Keycloak/Authentik client |
The `assert` task at the top of the role will fail fast if any secret is
missing or malformed.
## First login
OpnForm in self-hosted mode does **not** ship a pre-seeded admin user.
The first user to register becomes the owner of the default workspace,
and further public registration is disabled afterwards (additional
users must be invited via the Admin UI).
This role supports two ways to create that first user:
### Option A — automated bootstrap (recommended)
Set `opnform_admin_email` and `opnform_admin_password` (ideally from
Vault / OpenBao). The role then POSTs to `/api/register` after the
API container is healthy, skipping the setup page entirely. The task
is idempotent: it does a login check first and only registers if the
user does not already exist.
```yaml
opnform_admin_name: "Administrator" # default
opnform_admin_email: "admin@example.com"
opnform_admin_password: "{{ vault_opnform_admin_password }}"
```
Password rules enforced by OpnForm: minimum 8 characters, at least one
letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`.
### Option B — manual setup page
Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit
`opnform_base_url` and complete the setup page in the browser.
## OIDC setup
Set `opnform_oidc_enabled: true` and the role creates an
IdentityConnection on the admin's default workspace via
`POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a
single OIDC connection per workspace, so the task is idempotent (GETs
existing connections first and skips if any exist).
**Prerequisite**: the admin bootstrap must be configured
(`opnform_admin_email` + `opnform_admin_password`). The OIDC API
requires an authenticated admin token; the role logs in with those
credentials to make the call. The validation block fails fast if OIDC
is enabled without admin credentials.
### Required when `opnform_oidc_enabled: true`
| Variable | Notes |
|---|---|
| `opnform_oidc_client_secret` | from your IdP, never commit |
| `opnform_oidc_domain` | email domain that triggers OIDC (e.g. `example.com`) |
### Tunables (defaults shown)
```yaml
opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard"
opnform_oidc_client_id: "opnform-digitalboard"
opnform_oidc_client_name: "Digitalboard" # display name in UI
opnform_oidc_slug: "oidc" # used in /auth/{slug}/callback
opnform_oidc_scopes: [openid, profile, email, groups]
```
### Group → role mapping
Two ways, the list takes precedence:
```yaml
# Option 1: full list (any number of mappings)
opnform_oidc_group_role_mappings:
- idp_group: "opnform-admins"
role: admin
- idp_group: "opnform-editors"
role: editor
# Option 2: convenience — single admin group
opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin
```
Valid roles: `owner`, `admin`, `editor`, `member`.
## Example playbook
```yaml
- name: Deploy OpnForm service
hosts: opnform_servers
become: true
roles:
- digitalboard.core.opnform
```
With inventory variables:
```yaml
# group_vars/opnform_servers.yml
opnform_domain: forms.digitalboard.ch
opnform_base_url: "https://forms.digitalboard.ch"
opnform_app_key: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/opnform',
mount_point='kv').data.data.app_key }}"
opnform_jwt_secret: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/opnform',
mount_point='kv').data.data.jwt_secret }}"
opnform_front_api_secret: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/opnform',
mount_point='kv').data.data.front_api_secret }}"
opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/opnform',
mount_point='kv').data.data.db_password }}"
```

View file

@ -0,0 +1,101 @@
#SPDX-License-Identifier: MIT-0
---
# defaults file for opnform
# Base directory configuration (inherited from base role or defined here)
docker_compose_base_dir: /etc/docker/compose
docker_volume_base_dir: /srv/data
# opnform-specific configuration
opnform_service_name: opnform
opnform_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ opnform_service_name }}"
opnform_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ opnform_service_name }}"
opnform_storage_dir: "{{ opnform_docker_volume_dir }}/storage"
opnform_db_data_dir: "{{ opnform_docker_volume_dir }}/db"
opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis"
# Service configuration
opnform_domain: "forms.local.test"
opnform_base_url: "https://forms.local.test"
# Images
opnform_api_image: "jhumanj/opnform-api:latest"
opnform_client_image: "jhumanj/opnform-client:latest"
opnform_redis_image: "redis:7"
opnform_db_image: "postgres:16"
opnform_ingress_image: "nginx:1"
# REQUIRED SECRETS — generate with: openssl rand -base64 32
# Always prefix opnform_app_key with "base64:"
# Provide via OpenBao lookup, Ansible Vault or extra-vars.
# Never commit real keys to version control.
opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko="
opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b"
opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d"
# Database credentials
opnform_db_name: "opnform"
opnform_db_user: "opnform"
opnform_db_password: "xtNLUVc2ajcWictqWXWkLR"
# Admin bootstrap — when email+password are set, the role creates the
# first user via OpnForm's /api/register endpoint, skipping the
# self-hosted setup page. Leave both empty to keep the manual setup flow.
# Password must satisfy OpnForm's rules: min 8 chars, contain a letter,
# a digit and one of @$!%*#?&-_+=.,:;<>^()[]{}|~
# Provide via OpenBao, Ansible Vault or extra-vars.
opnform_admin_name: "Administrator"
opnform_admin_email: ""
opnform_admin_password: ""
opnform_admin_hear_about_us: "ansible"
# PHP configuration
opnform_php_memory_limit: "1G"
opnform_php_max_execution_time: "600"
opnform_php_upload_max_filesize: "64M"
opnform_php_post_max_size: "64M"
# Nginx ingress
opnform_nginx_max_body_size: "64m"
# Mail configuration (optional — defaults to log driver)
opnform_mail_mailer: "log"
opnform_mail_host: ""
opnform_mail_port: ""
opnform_mail_username: ""
opnform_mail_password: ""
opnform_mail_encryption: ""
opnform_mail_from_address: "noreply@digitalboard.ch"
opnform_mail_from_name: "OpnForm"
# OIDC configuration — when enabled, the role auto-creates an
# IdentityConnection in the first workspace via OpnForm's API after the
# admin bootstrap. Requires opnform_admin_email/_password to be set
# (the API call needs an authenticated admin token).
opnform_oidc_enabled: false
opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard"
opnform_oidc_client_id: "opnform-digitalboard"
opnform_oidc_client_secret: ""
opnform_oidc_client_name: "Digitalboard"
# OpnForm-side identifier used in /auth/{slug}/callback. Lowercase
# alphanumeric + hyphens, unique across all identity_connections.
opnform_oidc_slug: "oidc"
# Email domain that triggers OIDC login for matching users (e.g. users
# with @example.com emails are redirected to the IdP). Required when
# opnform_oidc_enabled is true.
opnform_oidc_domain: ""
opnform_oidc_scopes:
- openid
- profile
- email
- groups
# Convenience: maps a single IdP group to the OpnForm "admin" role.
# Ignored when opnform_oidc_group_role_mappings is non-empty.
opnform_oidc_admin_group: "opnform-admins"
# Full group-to-role mapping list. Takes precedence over the convenience
# var. Each item: {idp_group: "<group name>", role: "owner|admin|editor|member"}
opnform_oidc_group_role_mappings: []
# Traefik configuration
opnform_traefik_network: "proxy"
opnform_use_ssl: true

View file

@ -0,0 +1,8 @@
#SPDX-License-Identifier: MIT-0
---
# handlers file for opnform
- name: restart opnform
community.docker.docker_compose_v2:
project_src: "{{ opnform_docker_compose_dir }}"
state: restarted

View file

@ -0,0 +1,35 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
# If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
# Choose a valid license ID from https://spdx.org - some suggested licenses:
# - BSD-3-Clause (default)
# - MIT
# - GPL-2.0-or-later
# - GPL-3.0-only
# - Apache-2.0
# - CC-BY-4.0
license: license (GPL-2.0-or-later, MIT, etc)
min_ansible_version: 2.2
# If this a Container Enabled role, provide the minimum Ansible Container version.
# min_ansible_container_version:
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -0,0 +1,265 @@
#SPDX-License-Identifier: MIT-0
---
# tasks file for opnform
# =====================================================================
# 0. VALIDATION
# =====================================================================
- name: Validate required secrets
ansible.builtin.assert:
that:
- opnform_app_key | length > 0
- opnform_app_key is match('^base64:[A-Za-z0-9+/=]+$')
- opnform_jwt_secret | length > 0
- opnform_front_api_secret | length > 0
- opnform_db_password | length > 0
fail_msg: >-
OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret,
opnform_front_api_secret and opnform_db_password.
Generate with: openssl rand -base64 32
The app_key MUST be prefixed with "base64:"
Provide via OpenBao, Ansible Vault or extra-vars.
success_msg: Secrets validation passed
- name: Validate OIDC configuration when enabled
ansible.builtin.assert:
that:
- opnform_oidc_client_secret | length > 0
- opnform_oidc_domain | length > 0
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
fail_msg: >-
When opnform_oidc_enabled is true, you must set:
- opnform_oidc_client_secret
- opnform_oidc_domain (email domain that triggers OIDC)
- opnform_admin_email / opnform_admin_password
(the OIDC API requires an authenticated admin; the role logs in
with these credentials to POST the connection)
when: opnform_oidc_enabled | bool
# =====================================================================
# 1. PREPARATION
# =====================================================================
- name: Ensure required packages are installed
ansible.builtin.package:
name:
- python3-docker
state: present
- name: Create docker compose directory
ansible.builtin.file:
path: "{{ opnform_docker_compose_dir }}"
state: directory
mode: '0755'
- name: Create OpnForm data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ opnform_docker_volume_dir }}"
- "{{ opnform_storage_dir }}"
- "{{ opnform_db_data_dir }}"
- "{{ opnform_redis_data_dir }}"
# =====================================================================
# 2. CONFIGURATION FILES
# =====================================================================
- name: Deploy nginx ingress configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: "{{ opnform_docker_compose_dir }}/nginx.conf"
mode: '0644'
notify: restart opnform
- name: Deploy docker-compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
notify: restart opnform
# =====================================================================
# 3. CONTAINER STARTUP
# =====================================================================
- name: Start opnform containers
community.docker.docker_compose_v2:
project_src: "{{ opnform_docker_compose_dir }}"
state: present
wait: true
wait_timeout: 180
# =====================================================================
# 4. WAIT FOR API READINESS
# =====================================================================
- name: Wait for API container to be healthy
ansible.builtin.command:
cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api
register: api_health
until: api_health.stdout == "healthy"
retries: 30
delay: 10
changed_when: false
# =====================================================================
# 5. ADMIN BOOTSTRAP (optional)
# =====================================================================
# Skips the self-hosted setup page by registering the first user via
# OpnForm's /api/register endpoint. Idempotent: a successful login
# attempt with the same credentials means the user already exists.
- name: Check if OpnForm admin user already exists
ansible.builtin.uri:
url: "https://127.0.0.1/api/login"
method: POST
headers:
Host: "{{ opnform_domain }}"
body_format: json
body:
email: "{{ opnform_admin_email }}"
password: "{{ opnform_admin_password }}"
status_code: [200, 401, 422]
validate_certs: false
register: opnform_admin_login
when:
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- name: Create OpnForm admin user via /api/register
ansible.builtin.uri:
url: "https://127.0.0.1/api/register"
method: POST
headers:
Host: "{{ opnform_domain }}"
body_format: json
body:
name: "{{ opnform_admin_name }}"
email: "{{ opnform_admin_email }}"
password: "{{ opnform_admin_password }}"
password_confirmation: "{{ opnform_admin_password }}"
hear_about_us: "{{ opnform_admin_hear_about_us }}"
status_code: [200, 201]
validate_certs: false
no_log: true
when:
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- opnform_admin_login.status != 200
# =====================================================================
# 6. OIDC IDENTITY CONNECTION (optional)
# =====================================================================
# Creates a single OIDC connection on the admin's default workspace.
# OpnForm enforces one OIDC connection per workspace, so this block is
# idempotent: we GET existing connections first and skip if any exists.
- name: Log in as admin to obtain OIDC API token
ansible.builtin.uri:
url: "https://127.0.0.1/api/login"
method: POST
headers:
Host: "{{ opnform_domain }}"
body_format: json
body:
email: "{{ opnform_admin_email }}"
password: "{{ opnform_admin_password }}"
status_code: 200
validate_certs: false
register: opnform_oidc_token
no_log: true
when: opnform_oidc_enabled | bool
- name: Fetch admin's workspaces
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces"
method: GET
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
status_code: 200
validate_certs: false
register: opnform_workspaces
no_log: true
when: opnform_oidc_enabled | bool
- name: Fetch existing OIDC connections for the default workspace
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections"
method: GET
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
status_code: 200
validate_certs: false
register: opnform_existing_oidc
no_log: true
when: opnform_oidc_enabled | bool
- name: Resolve OIDC group-role mappings
ansible.builtin.set_fact:
_opnform_oidc_group_role_mappings: >-
{{
opnform_oidc_group_role_mappings
if (opnform_oidc_group_role_mappings | length > 0)
else
([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}]
if (opnform_oidc_admin_group | length > 0) else [])
}}
when: opnform_oidc_enabled | bool
- name: Create OIDC identity connection
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections"
method: POST
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
body_format: json
body:
name: "{{ opnform_oidc_client_name }}"
slug: "{{ opnform_oidc_slug }}"
domain: "{{ opnform_oidc_domain }}"
issuer: "{{ opnform_oidc_issuer }}"
client_id: "{{ opnform_oidc_client_id }}"
client_secret: "{{ opnform_oidc_client_secret }}"
scopes: "{{ opnform_oidc_scopes }}"
enabled: true
options:
require_state: true
group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}"
status_code: [201]
validate_certs: false
no_log: true
when:
- opnform_oidc_enabled | bool
- opnform_existing_oidc.json | length == 0
- name: Display deployment info
ansible.builtin.debug:
msg: |-
OpnForm deployed at {{ opnform_base_url }}
{% if opnform_admin_email | length > 0 %}
Admin user bootstrapped:
Email: {{ opnform_admin_email }}
Password: (from opnform_admin_password)
{% else %}
No admin bootstrap configured — visit {{ opnform_base_url }} and
complete the self-hosted setup page to create the first user.
Set opnform_admin_email + opnform_admin_password to automate this.
{% endif %}
{% if opnform_oidc_enabled %}
OIDC: connection "{{ opnform_oidc_client_name }}" bootstrapped
(slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }})
Users with @{{ opnform_oidc_domain }} addresses will be
redirected to {{ opnform_oidc_issuer }} on login.
{% else %}
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)
{% endif %}

View file

@ -0,0 +1,133 @@
---
services:
api: &api-environment
image: jhumanj/opnform-api:latest
container_name: opnform-api
volumes: &api-environment-volumes
- opnform_storage:/usr/share/nginx/html/storage:rw
environment: &api-env
APP_ENV: production
# Database settings
DB_HOST: db
REDIS_HOST: redis
DB_DATABASE: ${DB_DATABASE:-forge}
DB_USERNAME: ${DB_USERNAME:-forge}
DB_PASSWORD: ${DB_PASSWORD:-forge}
DB_CONNECTION: ${DB_CONNECTION:-pgsql}
# PHP Configuration
PHP_MEMORY_LIMIT: "1G"
PHP_MAX_EXECUTION_TIME: "600"
PHP_UPLOAD_MAX_FILESIZE: "64M"
PHP_POST_MAX_SIZE: "64M"
env_file:
- ./api/.env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy # Depend on redis being healthy too
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
interval: 30s
timeout: 15s
retries: 3
start_period: 60s
api-worker:
<<: *api-environment
container_name: opnform-api-worker
command: ["php", "artisan", "queue:work"]
environment:
<<: *api-env
APP_ENV: production
healthcheck:
test:
["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
api-scheduler:
<<: *api-environment
container_name: opnform-api-scheduler
command: ["php", "artisan", "schedule:work"]
environment:
<<: *api-env
APP_ENV: production
healthcheck:
test:
[
"CMD-SHELL",
"php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1",
]
interval: 60s
timeout: 30s
retries: 3
start_period: 70s # Allow time for first scheduled run and cache write
ui:
image: jhumanj/opnform-client:latest
container_name: opnform-client
env_file:
- ./client/.env
depends_on:
api:
condition: service_healthy
healthcheck:
test:
["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 45s
redis:
image: redis:7
container_name: opnform-redis
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 30s
timeout: 5s
db:
image: postgres:16
container_name: opnform-db
environment:
POSTGRES_DB: ${DB_DATABASE:-forge}
POSTGRES_USER: ${DB_USERNAME:-forge}
POSTGRES_PASSWORD: ${DB_PASSWORD:-forge}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-forge}"]
interval: 30s
timeout: 5s
volumes:
- postgres-data:/var/lib/postgresql/data
ingress:
image: nginx:1
container_name: opnform-ingress
volumes:
- ./docker/nginx.conf:/etc/nginx/templates/default.conf.template
ports:
- 80:80
environment:
- NGINX_MAX_BODY_SIZE=64m
depends_on:
api:
condition: service_started
ui:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
postgres-data:
opnform_storage:
redis-data:

View file

@ -0,0 +1,189 @@
#---------------------------------------------------------------------#
# OpnForm — Beautiful open-source form builder #
#---------------------------------------------------------------------#
services:
api: &api-service
image: {{ opnform_api_image }}
container_name: opnform-api
restart: unless-stopped
volumes:
- {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw
environment: &api-env
APP_ENV: production
APP_KEY: "{{ opnform_app_key }}"
APP_URL: "{{ opnform_base_url }}"
APP_DEBUG: "false"
SELF_HOSTED: "true"
LOG_CHANNEL: errorlog
LOG_LEVEL: info
DB_CONNECTION: pgsql
DB_HOST: db
DB_PORT: "5432"
DB_DATABASE: "{{ opnform_db_name }}"
DB_USERNAME: "{{ opnform_db_user }}"
DB_PASSWORD: "{{ opnform_db_password }}"
REDIS_HOST: redis
REDIS_PORT: "6379"
CACHE_STORE: redis
CACHE_DRIVER: redis
QUEUE_CONNECTION: redis
SESSION_DRIVER: redis
SESSION_LIFETIME: "120"
BROADCAST_CONNECTION: log
FILESYSTEM_DISK: local
FILESYSTEM_DRIVER: local
LOCAL_FILESYSTEM_VISIBILITY: public
MAIL_MAILER: "{{ opnform_mail_mailer }}"
MAIL_HOST: "{{ opnform_mail_host }}"
MAIL_PORT: "{{ opnform_mail_port }}"
MAIL_USERNAME: "{{ opnform_mail_username }}"
MAIL_PASSWORD: "{{ opnform_mail_password }}"
MAIL_ENCRYPTION: "{{ opnform_mail_encryption }}"
MAIL_FROM_ADDRESS: "{{ opnform_mail_from_address }}"
MAIL_FROM_NAME: "{{ opnform_mail_from_name }}"
JWT_TTL: "1440"
JWT_SECRET: "{{ opnform_jwt_secret }}"
PHP_MEMORY_LIMIT: "{{ opnform_php_memory_limit }}"
PHP_MAX_EXECUTION_TIME: "{{ opnform_php_max_execution_time }}"
PHP_UPLOAD_MAX_FILESIZE: "{{ opnform_php_upload_max_filesize }}"
PHP_POST_MAX_SIZE: "{{ opnform_php_post_max_size }}"
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
networks:
- opnform-internal
api-worker:
<<: *api-service
container_name: opnform-api-worker
command: ["php", "artisan", "queue:work"]
environment:
<<: *api-env
IS_API_WORKER: "true"
healthcheck:
test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
api-scheduler:
<<: *api-service
container_name: opnform-api-scheduler
command: ["php", "artisan", "schedule:work"]
healthcheck:
test:
- "CMD-SHELL"
- "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1"
interval: 60s
timeout: 30s
retries: 3
start_period: 70s
ui:
image: {{ opnform_client_image }}
container_name: opnform-ui
restart: unless-stopped
environment:
NUXT_PUBLIC_APP_URL: "{{ opnform_base_url }}"
NUXT_PUBLIC_API_BASE: "/api"
NUXT_PRIVATE_API_BASE: "http://ingress/api"
NUXT_PUBLIC_ENV: production
FRONT_API_SECRET: "{{ opnform_front_api_secret }}"
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 45s
networks:
- opnform-internal
redis:
image: {{ opnform_redis_image }}
container_name: opnform-redis
restart: unless-stopped
volumes:
- {{ opnform_redis_data_dir }}:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 30s
timeout: 5s
networks:
- opnform-internal
db:
image: {{ opnform_db_image }}
container_name: opnform-db
restart: unless-stopped
environment:
POSTGRES_DB: "{{ opnform_db_name }}"
POSTGRES_USER: "{{ opnform_db_user }}"
POSTGRES_PASSWORD: "{{ opnform_db_password }}"
volumes:
- {{ opnform_db_data_dir }}:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ opnform_db_user }}"]
interval: 30s
timeout: 5s
networks:
- opnform-internal
ingress:
image: {{ opnform_ingress_image }}
container_name: opnform-ingress
restart: unless-stopped
volumes:
- ./nginx.conf:/etc/nginx/templates/default.conf.template:ro
environment:
NGINX_MAX_BODY_SIZE: "{{ opnform_nginx_max_body_size }}"
depends_on:
api:
condition: service_started
ui:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- opnform-internal
- {{ opnform_traefik_network }}
labels:
- traefik.enable=true
- traefik.docker.network={{ opnform_traefik_network }}
- traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`)
{% if opnform_use_ssl %}
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ opnform_service_name }}.tls=true
{% else %}
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=web
{% endif %}
- traefik.http.services.{{ opnform_service_name }}.loadbalancer.server.port=80
networks:
opnform-internal:
driver: bridge
{{ opnform_traefik_network }}:
external: true

View file

@ -0,0 +1,43 @@
map $original_uri $api_uri {
~^/api(/.*$) $1;
default $original_uri;
}
server {
listen 80;
server_name {{ opnform_domain }};
root /app/public;
client_max_body_size {% raw %}${NGINX_MAX_BODY_SIZE}{% endraw %};
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://ui:3000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
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 api:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php;
fastcgi_param REQUEST_URI $api_uri;
}
}

View file

@ -0,0 +1,2 @@
#SPDX-License-Identifier: MIT-0
localhost

View file

@ -0,0 +1,6 @@
#SPDX-License-Identifier: MIT-0
---
- hosts: localhost
remote_user: root
roles:
- opnform

View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for homarr