# 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 }}" ```