Compare commits

...

14 commits

Author SHA1 Message Date
Simon Bärlocher
30f3c16b59
docs(opnform): add meta/argument_specs.yml
50 typed options covering the full defaults file plus the OIDC subschema
(group_role_mappings with idp_group + role choices). Required secrets
(app_key, jwt_secret, front_api_secret, db_password) marked
required: true so ansible refuses the play with a clear error before
the validate task even runs.

Loads cleanly through ansible-core's ArgumentSpecValidator. Matches the
spec convention introduced for traefik, authentik, drawio, garage and
nextcloud.
2026-05-26 14:58:36 +02:00
Simon Bärlocher
fb81f60f9d
fix(opnform): drop production-looking secrets from defaults
opnform_app_key, opnform_jwt_secret, opnform_front_api_secret and
opnform_db_password shipped as real base64 strings in defaults — they
look like production secrets that just happen to be public. Set all
four to '' and rely on the existing Validate task (and the new
argument_specs marking them required) to fail fast when an inventory
forgets to override them.

Mirror the docstring comment to show how to generate each one with
openssl.
2026-05-26 14:58:18 +02:00
Simon Bärlocher
48d12a1b4a
fix(opnform): address review feedback on vars header and meta boilerplate
* vars/main.yml: header was 'vars file for homarr' (copy-paste from the
  homarr role). Fixed to 'vars file for opnform'. File body is empty.
* meta/main.yml: replace ansible-galaxy init boilerplate with real
  metadata — author, description, license (MIT-0), min_ansible_version
  set to '2.15' as a string (galaxy schema requires str), galaxy_tags
  for discovery, and an empty dependencies list.

The third inline finding (dead roles/opnform/templates/compose.yml.j2)
is resolved by dropping the WIP commit a6f301e during the rebase rather
than removing it in a separate commit — the file no longer exists in
the rebased history.
2026-05-26 14:58:10 +02:00
03af64ca2c
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-26 14:54:35 +02:00
53e80ad7be
chore: add new role for OpnForm 2026-05-26 14:47:57 +02:00
78095cca1d Merge remote-tracking branch 'origin/main' 2026-05-20 12:41:52 +02:00
61193e26f4
refactor(homarr): extract layout packing to filter plugin 2026-05-19 11:19:29 +02:00
27ed51ee95 chore: ignore python bytecode cache 2026-05-18 18:02:59 +02:00
e0cb1ac68c Merge branch 'feature/homarr' 2026-05-18 17:25:15 +02:00
bbbd1c8940 fix: (Homarr) removed small mistakenly added entry in defaults 2026-05-18 10:47:06 +00:00
1c7ecabcaf Merge remote-tracking branch 'origin/feature/homarr' into feature/homarr 2026-05-18 09:45:28 +02:00
422b196831
Chore: add admin user and seed staging
added creation of the admin user, the basic homeboard and all basic setup tasks.
Todo: Cleanup
2026-04-07 16:58:28 +02:00
d3bdb1fdec
chore: base config and deployment for role homarr 2026-01-23 15:47:15 +01:00
029b1a86d4
chore: add new boilerplate role for homarr 2026-01-23 15:47:15 +01:00
18 changed files with 1437 additions and 59 deletions

4
.gitignore vendored
View file

@ -1 +1,5 @@
/.idea/ /.idea/
__pycache__/
*.pyc
plugins/lookup/__pycache__/

View file

@ -13,7 +13,7 @@ and customizable application tiles.
- a local admin user with bcrypt-hashed password - a local admin user with bcrypt-hashed password
- OIDC and credentials admin groups with full permissions - OIDC and credentials admin groups with full permissions
- application tiles defined in `homarr_apps`, auto-laid-out across all - application tiles defined in `homarr_apps`, auto-laid-out across all
three screen sizes three screen sizes via the bundled `homarr_compute_layouts` filter
- Skips the onboarding wizard so the instance is usable right after deploy - Skips the onboarding wizard so the instance is usable right after deploy
- Restarts the container via handler when the seed or compose file changes - Restarts the container via handler when the seed or compose file changes
@ -100,6 +100,53 @@ homarr_apps:
width: 2 width: 2
``` ```
## Layout filter plugin
The grid-packing algorithm that places tiles on the desktop, tablet
and mobile layouts lives in `filter_plugins/homarr_layout.py` rather
than inside the Jinja seed template. This keeps the SQL template
readable and lets the algorithm be unit-tested in isolation.
The filter is invoked once from `tasks/main.yml`:
```yaml
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
```
This produces a `homarr_layout` fact with two keys, both consumed by
`templates/homarr_seed.sql.j2`:
| Key | Shape | Purpose |
|---|---|---|
| `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` |
| `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit |
The filter signature accepts custom column counts if Homarr ever
changes the breakpoint widths:
```jinja
{{ homarr_apps | homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }}
```
To debug a layout without running the full deploy, run the play with
`-vv` — the `Show computed app layouts` task dumps the full
`homarr_layout` fact.
### Running the filter tests
The filter is covered by unit tests in
`filter_plugins/tests/test_homarr_layout.py`:
```bash
pip install pytest ansible-core
pytest filter_plugins/tests/
```
15 tests cover packing, width clamping, height/section-height,
input validation and custom grid sizes.
## First login ## First login
After the role completes, log in at `{{ homarr_base_url }}` with: After the role completes, log in at `{{ homarr_base_url }}` with:

View file

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

View file

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT-0
"""Custom Ansible filter plugin for computing Homarr grid layouts.
The Homarr SQL seed needs item_layout rows for three breakpoints
(desktop / tablet / mobile). Rather than embedding the packing
algorithm in Jinja with namespace gymnastics, this filter does the
computation in Python and hands the seed template a ready-to-render
data structure.
Usage in tasks:
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
The result is a dict with two keys:
apps original homarr_apps in order, each enriched with
'desktop', 'tablet', 'mobile' sub-dicts of
{'x', 'y', 'w', 'h'} ready for SQL templating.
section_height dict with 'desktop', 'tablet', 'mobile' keys
giving the minimum height (in grid cells) the
parent section must have to fit all tiles.
"""
from ansible.errors import AnsibleFilterError
def _pack(apps, cols):
"""Greedy left-to-right packing into a fixed-column grid.
Width values larger than the grid are clamped to the grid width
rather than overflowing so a tile declared with width=8 still
renders on the 6-column tablet grid (as a full-width tile) and on
the 2-column mobile grid (as a full-width tile) without breaking
the layout.
Returns (placements, total_height) where placements is a list of
{'id', 'x', 'y', 'w', 'h'} dicts, one per input app, in the same
order. total_height is the y-coordinate of the bottom of the last
occupied row (i.e. max(y + h) across placements).
"""
x = 0
y = 0
row_h = 0
max_y = 0
placements = []
for app in apps:
w = min(int(app.get('width', 1)), cols)
h = int(app.get('height', 1))
# Wrap to the next row when the tile would overflow the grid.
if x + w > cols:
x = 0
y += row_h
row_h = 0
placements.append({
'id': app['id'],
'x': x,
'y': y,
'w': w,
'h': h,
})
x += w
if h > row_h:
row_h = h
if y + h > max_y:
max_y = y + h
return placements, max_y
def homarr_compute_layouts(apps, desktop_cols=10, tablet_cols=6,
mobile_cols=2):
"""Compute responsive layouts for a list of Homarr apps.
Input validation is intentionally strict a malformed apps list
should fail the play with a clear message rather than produce a
broken SQL seed and a silently misconfigured dashboard.
Note: uniqueness of app ids is NOT checked here. The role's
`Validate homarr_apps have unique ids` assert task runs earlier
and is the single source of truth for that check.
"""
if not isinstance(apps, list):
raise AnsibleFilterError(
"homarr_compute_layouts: expected a list of apps, "
"got {0}".format(type(apps).__name__)
)
for index, app in enumerate(apps):
if not isinstance(app, dict):
raise AnsibleFilterError(
"homarr_compute_layouts: app at index {0} is not a "
"dict (got {1})".format(index, type(app).__name__)
)
for required in ('id', 'width'):
if required not in app:
raise AnsibleFilterError(
"homarr_compute_layouts: app at index {0} is "
"missing required key '{1}'".format(index, required)
)
desktop, h_desktop = _pack(apps, desktop_cols)
tablet, h_tablet = _pack(apps, tablet_cols)
mobile, h_mobile = _pack(apps, mobile_cols)
enriched = []
for src, d, t, m in zip(apps, desktop, tablet, mobile):
enriched.append({
**src,
'desktop': {'x': d['x'], 'y': d['y'], 'w': d['w'], 'h': d['h']},
'tablet': {'x': t['x'], 'y': t['y'], 'w': t['w'], 'h': t['h']},
'mobile': {'x': m['x'], 'y': m['y'], 'w': m['w'], 'h': m['h']},
})
# Floor section height at 1 so the section stays visible even
# when homarr_apps is empty.
return {
'apps': enriched,
'section_height': {
'desktop': max(h_desktop, 1),
'tablet': max(h_tablet, 1),
'mobile': max(h_mobile, 1),
},
}
class FilterModule(object):
"""Ansible filter plugin entry point."""
def filters(self):
return {
'homarr_compute_layouts': homarr_compute_layouts,
}

View file

@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT-0
"""Unit tests for the homarr_layout filter plugin.
Run from the role root:
pytest filter_plugins/tests/
Requires `pytest` and `ansible-core` in the environment.
"""
import os
import sys
# Make the filter importable without having Ansible auto-discovery in
# the way (it would only run during a real `ansible-playbook` invocation).
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import pytest # noqa: E402
from ansible.errors import AnsibleFilterError # noqa: E402
from homarr_layout import homarr_compute_layouts # noqa: E402
def _app(app_id, width, height=1):
"""Build a minimal app dict for tests."""
return {
'id': app_id,
'name': app_id.title(),
'icon': 'https://example.com/{0}.png'.format(app_id),
'href': 'https://{0}.example.com'.format(app_id),
'width': width,
'height': height,
}
# ---------------------------------------------------------------------
# Happy-path packing
# ---------------------------------------------------------------------
def test_empty_apps_returns_empty_list_and_min_height():
result = homarr_compute_layouts([])
assert result['apps'] == []
# Even an empty grid keeps section_height >= 1 so the section
# renders in the UI.
assert result['section_height'] == {
'desktop': 1, 'tablet': 1, 'mobile': 1,
}
def test_single_app_positioned_at_origin_in_all_grids():
result = homarr_compute_layouts([_app('a', width=2)])
a = result['apps'][0]
assert a['desktop'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
assert a['tablet'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
assert a['mobile'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
def test_original_app_keys_are_preserved():
apps = [_app('nextcloud', width=2)]
apps[0]['description'] = 'Cloud Storage'
result = homarr_compute_layouts(apps)
a = result['apps'][0]
# Original fields survive the layout enrichment.
assert a['name'] == 'Nextcloud'
assert a['description'] == 'Cloud Storage'
assert a['icon'] == 'https://example.com/nextcloud.png'
def test_desktop_wraps_after_filling_row():
# 5 apps of width 2 fill the 10-col desktop row exactly; 6th wraps.
apps = [_app('a{0}'.format(i), width=2) for i in range(6)]
result = homarr_compute_layouts(apps)
assert result['apps'][4]['desktop'] == {'x': 8, 'y': 0, 'w': 2, 'h': 1}
assert result['apps'][5]['desktop'] == {'x': 0, 'y': 1, 'w': 2, 'h': 1}
def test_mobile_wraps_after_every_app():
# Mobile is only 2 cols wide → every app of width 2 starts a new row.
apps = [_app('a{0}'.format(i), width=2) for i in range(3)]
result = homarr_compute_layouts(apps)
assert [a['mobile']['y'] for a in result['apps']] == [0, 1, 2]
# ---------------------------------------------------------------------
# Width clamping
# ---------------------------------------------------------------------
def test_width_clamped_per_grid():
result = homarr_compute_layouts([_app('big', width=8)])
# Desktop has room (8 <= 10), tablet clamps to 6, mobile clamps to 2.
a = result['apps'][0]
assert a['desktop']['w'] == 8
assert a['tablet']['w'] == 6
assert a['mobile']['w'] == 2
def test_width_larger_than_desktop_still_clamps():
# A pathological width=20 still works — it just becomes a full-width
# tile on every grid.
result = homarr_compute_layouts([_app('huge', width=20)])
a = result['apps'][0]
assert a['desktop']['w'] == 10
assert a['tablet']['w'] == 6
assert a['mobile']['w'] == 2
# ---------------------------------------------------------------------
# Height handling
# ---------------------------------------------------------------------
def test_section_height_grows_with_rows():
# 6 apps of width 2 on desktop → 5 in row 1, 1 in row 2.
apps = [_app('a{0}'.format(i), width=2) for i in range(6)]
result = homarr_compute_layouts(apps)
assert result['section_height']['desktop'] == 2
# On mobile every app is on its own row.
assert result['section_height']['mobile'] == 6
def test_tall_app_extends_row_height():
apps = [
_app('tall', width=2, height=3),
_app('short', width=2, height=1),
]
result = homarr_compute_layouts(apps)
# Both fit in row 0 horizontally, but the section must be 3 tall.
assert result['section_height']['desktop'] == 3
def test_tall_app_pushes_subsequent_row_down():
# tall (h=3) fills full desktop width → next app wraps to y=3.
result = homarr_compute_layouts([
_app('tall', width=10, height=3),
_app('next', width=2, height=1),
])
assert result['apps'][1]['desktop'] == {'x': 0, 'y': 3, 'w': 2, 'h': 1}
# ---------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------
def test_rejects_non_list_input():
with pytest.raises(AnsibleFilterError, match='expected a list'):
homarr_compute_layouts('not a list')
def test_rejects_non_dict_entry():
with pytest.raises(AnsibleFilterError, match='not a dict'):
homarr_compute_layouts(['just a string'])
def test_rejects_app_without_id():
with pytest.raises(AnsibleFilterError, match="missing required key 'id'"):
homarr_compute_layouts([{'name': 'no id', 'width': 2}])
def test_rejects_app_without_width():
with pytest.raises(AnsibleFilterError,
match="missing required key 'width'"):
homarr_compute_layouts([{'id': 'no-width', 'name': 'x'}])
# ---------------------------------------------------------------------
# Configurable grid sizes
# ---------------------------------------------------------------------
def test_custom_grid_sizes():
# If Homarr ever switches to 12-col desktop, the filter still works.
result = homarr_compute_layouts(
[_app('a', width=4), _app('b', width=4), _app('c', width=4)],
desktop_cols=12, tablet_cols=8, mobile_cols=4,
)
# All three fit in desktop row 0 (4+4+4 = 12).
assert [a['desktop']['x'] for a in result['apps']] == [0, 4, 8]
assert result['section_height']['desktop'] == 1

View file

@ -128,7 +128,23 @@
no_log: true no_log: true
# ===================================================================== # =====================================================================
# 5. SEED DATABASE (only if local admin user does not exist yet) # 5. COMPUTE APP LAYOUTS
# =====================================================================
# Packing is done by the homarr_compute_layouts filter plugin (Python)
# rather than inline Jinja, so the seed template stays readable and the
# packing algorithm can be unit-tested in isolation.
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
- name: Show computed app layouts
ansible.builtin.debug:
var: homarr_layout
verbosity: 1
# =====================================================================
# 6. SEED DATABASE (only if local admin user does not exist yet)
# ===================================================================== # =====================================================================
- name: Check if local admin user exists - name: Check if local admin user exists

View file

@ -1,35 +1,13 @@
{#- {#-
Auto-layout packing macro. Homarr database seed.
Greedy left-to-right packing of apps into a grid with `cols` columns. The packing algorithm previously lived in this template as a Jinja
Returns the list of apps with computed x/y/w/h fields. `pack()` macro with from_json/to_json round-trips. It has been
extracted to the `homarr_compute_layouts` filter plugin (see
Width is clamped to cols (so an app wider than the grid is downsized filter_plugins/homarr_layout.py) and the result is provided as the
rather than overflowing). Height is taken as-is. `homarr_layout` fact set in tasks/main.yml. This template therefore
only renders SQL — no logic.
-#} -#}
{%- macro pack(apps, cols) -%}
{%- set ns = namespace(x=0, y=0, row_h=0, out=[]) -%}
{%- for app in apps -%}
{%- set w = [app.width, cols] | min -%}
{%- set h = app.height | default(1) -%}
{%- if ns.x + w > cols -%}
{%- set ns.x = 0 -%}
{%- set ns.y = ns.y + ns.row_h -%}
{%- set ns.row_h = 0 -%}
{%- endif -%}
{%- set _ = ns.out.append({'id': app.id, 'x': ns.x, 'y': ns.y, 'w': w, 'h': h}) -%}
{%- set ns.x = ns.x + w -%}
{%- if h > ns.row_h -%}
{%- set ns.row_h = h -%}
{%- endif -%}
{%- endfor -%}
{{- ns.out | to_json -}}
{%- endmacro -%}
{%- set desktop_layout = pack(homarr_apps, 10) | from_json -%}
{%- set tablet_layout = pack(homarr_apps, 6) | from_json -%}
{%- set mobile_layout = pack(homarr_apps, 2) | from_json -%}
BEGIN TRANSACTION; BEGIN TRANSACTION;
-- ===================================================================== -- =====================================================================
@ -148,11 +126,14 @@ VALUES (
'{"json": {}}' '{"json": {}}'
); );
-- Section height is sized to fit the computed layout (see
-- homarr_compute_layouts filter). It grows automatically when more
-- apps or taller tiles are added.
INSERT OR REPLACE INTO section_layout (section_id, layout_id, parent_section_id, x_offset, y_offset, width, height) INSERT OR REPLACE INTO section_layout (section_id, layout_id, parent_section_id, x_offset, y_offset, width, height)
VALUES VALUES
('section-apps', 'layout-desktop', NULL, 0, 0, 10, 3), ('section-apps', 'layout-desktop', NULL, 0, 0, 10, {{ homarr_layout.section_height.desktop }}),
('section-apps', 'layout-tablet', NULL, 0, 0, 6, 4), ('section-apps', 'layout-tablet', NULL, 0, 0, 6, {{ homarr_layout.section_height.tablet }}),
('section-apps', 'layout-mobile', NULL, 0, 0, 2, 6); ('section-apps', 'layout-mobile', NULL, 0, 0, 2, {{ homarr_layout.section_height.mobile }});
-- Board permissions -- Board permissions
INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission) INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission)
@ -161,11 +142,11 @@ VALUES
('board-default', 'group-credentials-admin', 'full-access'); ('board-default', 'group-credentials-admin', 'full-access');
-- ===================================================================== -- =====================================================================
-- APPS (auto-generated from homarr_apps variable) -- APPS (positions pre-computed by homarr_compute_layouts filter)
-- ===================================================================== -- =====================================================================
{% if homarr_apps | length > 0 %} {% for app in homarr_layout.apps %}
{% for app in homarr_apps %} -- {{ app.name }}
INSERT OR IGNORE INTO app (id, name, description, icon_url, href) INSERT OR IGNORE INTO app (id, name, description, icon_url, href)
VALUES ( VALUES (
'app-{{ app.id }}', 'app-{{ app.id }}',
@ -184,28 +165,11 @@ VALUES (
'{"json": {}}' '{"json": {}}'
); );
{% endfor %}
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height) INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES VALUES
{% for entry in desktop_layout %} ('item-{{ app.id }}', 'section-apps', 'layout-desktop', {{ app.desktop.x }}, {{ app.desktop.y }}, {{ app.desktop.w }}, {{ app.desktop.h }}),
('item-{{ entry.id }}', 'section-apps', 'layout-desktop', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %} ('item-{{ app.id }}', 'section-apps', 'layout-tablet', {{ app.tablet.x }}, {{ app.tablet.y }}, {{ app.tablet.w }}, {{ app.tablet.h }}),
{% endfor %} ('item-{{ app.id }}', 'section-apps', 'layout-mobile', {{ app.mobile.x }}, {{ app.mobile.y }}, {{ app.mobile.w }}, {{ app.mobile.h }});
;
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES
{% for entry in tablet_layout %}
('item-{{ entry.id }}', 'section-apps', 'layout-tablet', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
{% endfor %} {% endfor %}
;
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES
{% for entry in mobile_layout %}
('item-{{ entry.id }}', 'section-apps', 'layout-mobile', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
{% endfor %}
;
{% endif %}
COMMIT; COMMIT;

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,109 @@
#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 — must be overridden per-inventory.
# Provide via OpenBao lookup, Ansible Vault or extra-vars.
# Never commit real keys to version control.
#
# Generate with:
# opnform_app_key: echo "base64:$(openssl rand -base64 32)"
# opnform_jwt_secret: openssl rand -hex 32
# opnform_front_api_secret: openssl rand -hex 32
#
# opnform_app_key MUST start with the prefix "base64:" — the validate
# task at the top of tasks/main.yml enforces this.
opnform_app_key: ""
opnform_jwt_secret: ""
opnform_front_api_secret: ""
# Database credentials. opnform_db_password must be overridden; the
# validate task fails fast on an empty value.
opnform_db_name: "opnform"
opnform_db_user: "opnform"
opnform_db_password: ""
# 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,220 @@
---
argument_specs:
main:
short_description: Deploy OpnForm (api + ui + db + redis + ingress) via Docker Compose.
description:
- Renders a Compose stack for the full OpnForm setup (PHP-FPM api,
Nuxt ui, Postgres, Redis, nginx ingress) and exposes it through
Traefik.
- Optionally bootstraps the first admin user via the OpnForm
C(/api/register) endpoint (skipping the self-hosted setup page)
and provisions a single OIDC identity connection in the default
workspace via the workspace API. Both bootstraps are idempotent.
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
opnform_service_name:
type: str
default: opnform
opnform_docker_compose_dir:
type: path
description: Defaults to C({{ docker_compose_base_dir }}/{{ opnform_service_name }}).
opnform_docker_volume_dir:
type: path
description: Defaults to C({{ docker_volume_base_dir }}/{{ opnform_service_name }}).
opnform_storage_dir:
type: path
description: OpnForm storage volume mounted into the api container.
opnform_db_data_dir:
type: path
opnform_redis_data_dir:
type: path
opnform_domain:
type: str
default: forms.local.test
description: Hostname used in the traefik Host rule.
opnform_base_url:
type: str
default: https://forms.local.test
description: Public URL OpnForm uses for APP_URL and NUXT_PUBLIC_APP_URL.
opnform_api_image:
type: str
default: jhumanj/opnform-api:latest
opnform_client_image:
type: str
default: jhumanj/opnform-client:latest
opnform_redis_image:
type: str
default: "redis:7"
opnform_db_image:
type: str
default: "postgres:16"
opnform_ingress_image:
type: str
default: "nginx:1"
opnform_app_key:
type: str
required: true
description:
- Laravel application key. Must be prefixed with C(base64:).
Generate with C(echo "base64:$(openssl rand -base64 32)").
Provide via OpenBao, Ansible Vault or extra-vars.
opnform_jwt_secret:
type: str
required: true
description: JWT signing secret. Generate with C(openssl rand -hex 32).
opnform_front_api_secret:
type: str
required: true
description: Shared secret between ui and api. Generate with C(openssl rand -hex 32).
opnform_db_name:
type: str
default: opnform
opnform_db_user:
type: str
default: opnform
opnform_db_password:
type: str
required: true
opnform_admin_name:
type: str
default: Administrator
opnform_admin_email:
type: str
default: ''
description:
- When non-empty (together with C(opnform_admin_password)) the role
bootstraps the first user via C(/api/register), skipping the
self-hosted setup page. Required when C(opnform_oidc_enabled=true).
opnform_admin_password:
type: str
default: ''
description:
- "Must satisfy OpnForm's policy: min 8 chars, letter + digit +
symbol from C(@$!%*#?&-_+=.,:;<>^()[]{}|~)."
opnform_admin_hear_about_us:
type: str
default: ansible
opnform_php_memory_limit:
type: str
default: 1G
opnform_php_max_execution_time:
type: str
default: "600"
opnform_php_upload_max_filesize:
type: str
default: 64M
opnform_php_post_max_size:
type: str
default: 64M
opnform_nginx_max_body_size:
type: str
default: 64m
opnform_mail_mailer:
type: str
default: log
choices: [log, smtp, ses, mailgun, postmark, sendmail]
opnform_mail_host:
type: str
default: ''
opnform_mail_port:
type: str
default: ''
opnform_mail_username:
type: str
default: ''
opnform_mail_password:
type: str
default: ''
opnform_mail_encryption:
type: str
default: ''
choices: ['', tls, ssl]
opnform_mail_from_address:
type: str
default: noreply@digitalboard.ch
opnform_mail_from_name:
type: str
default: OpnForm
opnform_oidc_enabled:
type: bool
default: false
description:
- "When true the role calls the workspace API to create a single
OIDC C(identity_connection) on the default workspace after the
admin bootstrap. Requires C(opnform_admin_email) +
C(opnform_admin_password) so the role can authenticate.
Idempotent: skipped when any connection already exists."
opnform_oidc_issuer:
type: str
default: https://auth.digitalboard.ch/realms/Digitalboard
description: OIDC issuer URL.
opnform_oidc_client_id:
type: str
default: opnform-digitalboard
opnform_oidc_client_secret:
type: str
default: ''
description: Required when C(opnform_oidc_enabled=true).
opnform_oidc_client_name:
type: str
default: Digitalboard
description: Display name shown in the OpnForm UI.
opnform_oidc_slug:
type: str
default: oidc
description:
- OpnForm-side identifier used in C(/auth/{slug}/callback). Lowercase
alphanumeric + hyphens, unique across all C(identity_connections).
opnform_oidc_domain:
type: str
default: ''
description:
- Email domain that triggers OIDC for matching users. Required
when C(opnform_oidc_enabled=true).
opnform_oidc_scopes:
type: list
elements: str
default: [openid, profile, email, groups]
opnform_oidc_admin_group:
type: str
default: opnform-admins
description:
- Convenience setting that maps a single IdP group to the OpnForm
C(admin) role. Ignored when C(opnform_oidc_group_role_mappings)
is non-empty.
opnform_oidc_group_role_mappings:
type: list
elements: dict
default: []
description:
- Full IdP-group -> OpnForm-role mapping. Takes precedence over
C(opnform_oidc_admin_group).
options:
idp_group:
type: str
required: true
description: Group name as it appears in the IdP groups claim.
role:
type: str
required: true
choices: [owner, admin, editor, member]
opnform_traefik_network:
type: str
default: proxy
opnform_use_ssl:
type: bool
default: true

View file

@ -0,0 +1,16 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: Tobias Wüst
description: Deploy OpnForm self-hosted form builder via Docker Compose behind Traefik
company: Digitalboard
license: MIT-0
min_ansible_version: "2.15"
galaxy_tags:
- opnform
- forms
- docker
- traefik
- oidc
dependencies: []

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,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 opnform