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].
|
||
|---|---|---|
| .. | ||
| defaults | ||
| handlers | ||
| meta | ||
| tasks | ||
| templates | ||
| tests | ||
| vars | ||
| README.md | ||
opnform
Deploy 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, andingress(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 toapi:9000 - Rewrite request URIs via the
$api_urimap - 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.
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)
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:
# 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
- name: Deploy OpnForm service
hosts: opnform_servers
become: true
roles:
- digitalboard.core.opnform
With inventory variables:
# 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 }}"