Compare commits

...

14 commits

Author SHA1 Message Date
Simon Bärlocher
a8954f525c
fix(opnform): align FRONT_API_SECRET across api and ui SSR path
The api service now also receives FRONT_API_SECRET so AuthenticateJWT
accepts the UI's server-side JWT forwards instead of blacklisting them
on UA mismatch. On the ui service the var is renamed FRONT_API_SECRET ->
NUXT_API_SECRET so Nuxt's runtimeConfig.apiSecret is actually populated
(NUXT_<key> convention) and injected as x-api-secret, short-circuiting
the UA-fingerprint check that otherwise 401s every reload.
2026-06-02 17:05:44 +02:00
Simon Bärlocher
3ace667b6c
feat(services): refine split-horizon OIDC routing and harden nextcloud patch
- authentik: address the rewrite service by compose service name instead
  of a network alias on the public FQDN, which shadowed extra_hosts pins
  and broke OIDC discovery for c-ares-based (Node) resolvers
- homarr: add homarr_extra_hosts to pin the IdP FQDN to a LAN IP so OIDC
  discovery stays in-network while the issuer matches the browser-facing URL
- opnform: add opnform_oidc_sso_redirect_root to 302 the root URL to the
  SSO path (deep-links untouched, /login?bypass=1 break-glass); restart
  ingress via container restart so envsubst re-renders nginx.conf
- nextcloud: make the UserConfig sed workaround fail loud on upstream
  drift instead of silently skipping (nextcloud/server#59629)
- gitignore: exclude the local .ansible/ collection cache
2026-06-02 13:44:08 +02:00
Simon Bärlocher
3236ca332f
docs(collection): document all roles and fix metadata drift
Replace ansible-galaxy init placeholders across the collection and
correct documentation that drifted from the code, after a multi-agent
review of every role README against its defaults, tasks and templates.

Collection level:
- README: role table for all 16 roles, requirements and role-ordering
- galaxy.yml: declare community.docker and community.general deps,
  real description/tags/urls; normalize license to MIT-0
- meta/runtime.yml: requires_ansible '>=2.15.0'
- plugins/README: document the homarr_layout filter and
  garage_credentials lookup instead of scaffold boilerplate

Per-role meta/main.yml and README for the placeholder roles
(389ds, authentik, authentik_outpost_ldap, base, collabora, drawio,
garage, homarr, httpbin, keycloak, nextcloud, opencloud, traefik).

Correctness fixes found during review:
- keycloak: wrong domain default, drop invented keycloak_cert_resolver,
  document the provisioning feature
- garage: root_domain is .s3.<first-entry>, not the bare domain
- opnform: jwt/front_api secrets use `openssl rand -hex 32`; align the
  validation fail_msg in tasks/main.yml accordingly
- send: S3 example references garage_s3_domains[0] (was singular)
- opencloud: document required opencloud_wopi_domain

License normalized to MIT-0 across galaxy.yml, role meta and READMEs to
match the SPDX headers.
2026-05-27 23:12:24 +02:00
Simon Bärlocher
19864d79b2
feat(services): multi-domain routing, split-horizon and OIDC hardening
Bundle of cross-role changes for the gymb services deployment:

- Traefik routers: OR-combine opnform/homarr/bookstack Host rules with new
  *_extra_domains (internal *.int.* FQDNs for a DMZ reverseproxy), and emit
  tls.certresolver only when traefik_cert_mode == acme (drawio, homarr,
  opnform, send).
- Split-horizon: bookstack_extra_hosts / opnform_extra_hosts add container
  /etc/hosts overrides so containers reach the IdP public FQDN over the LAN.
- bookstack: assert the OIDC issuer resolves concretely (reject "//v2.0"),
  allowing non-Entra IdPs that override bookstack_oidc_issuer.
- homarr: derive the bcrypt salt from the password digest so the admin hash
  is idempotent — no spurious template changes / container restarts.
- opnform: PATCH an existing OIDC connection instead of skipping (applies
  corrected inventory on re-run); add OIDC_FORCE_LOGIN (enabled only after
  bootstrap) and an optional direct-SSO ingress entrypoint.

Docs: READMEs and meta/argument_specs.yml updated for all new variables.
2026-05-27 23:12:24 +02:00
Simon Bärlocher
1dcff92240
docs(roles): add argument_specs and README for traefik, authentik, drawio, garage, nextcloud
Each of the five roles touched in this branch now ships:

* meta/argument_specs.yml: typed schema for every variable in
  defaults/main.yml plus the optional inputs surfaced via this
  branch (traefik_extra_hosts, authentik_host_rewrite_domains,
  authentik_proxy_apps.mode / .allowed_groups, drawio_extra_domains,
  drawio_authentik_forward_auth*, garage_webui_authentik_forward_auth*).
  All five specs load cleanly through ansible-core's
  ArgumentSpecValidator.

* README.md: replaces the ansible-galaxy boilerplate (where it was
  still in place) with a focused write-up — service vars, required
  secrets, ForwardAuth/idempotency notes, dependencies, and a working
  example playbook. authentik and garage READMEs are rewritten to cover
  the new knobs while preserving their existing content.
2026-05-27 23:12:24 +02:00
Simon Bärlocher
a9c33baed9
feat(drawio): support extra hostnames via drawio_extra_domains
Add drawio_extra_domains (list, default empty). The traefik Host rule
on the drawio router now expands to Host(<canonical>) || Host(<extra>)
... so the same container can answer on additional FQDNs — e.g. an
internal *.int.* name so a DMZ reverse-proxy can reach drawio via a
backend hostname covered by the local traefik cert.

Empty by default; behaviour unchanged for existing inventories.
2026-05-27 23:12:24 +02:00
Simon Bärlocher
60464e6d23
fix(nextcloud): in-container patch for UserConfig::getValueBool TypeError
nextcloud/server#59629: under PHP 8.x with OPcache,
UserConfig::getValueBool() passes a non-string from getTypedValue()
straight into strtolower(), throwing a TypeError on every authenticated
request once user_ldap is involved. Fix landed in master (PR #59646)
but no stable33 backport made it into 33.0.4.

Discover all compose-managed nextcloud containers, check whether the
`strtolower((string)` cast is already present, and `sed` it into
`lib/private/Config/UserConfig.php` on the ones that still ship the
broken version. Idempotent via grep guard so re-runs are no-ops.

Remove this block once the deployed image >= 33.0.4 ships the upstream fix.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
f0cd8ba432
fix(nextcloud): make occ-driven config tasks idempotent
Every `occ config:app:set` / `ldap:set-config` / `notify_push:setup`
call previously fired on every play, marking changed even when the
stored value already matched. Now we read the current value first and
only invoke the setter when it differs:

* richdocuments (collabora): pre-read wopi_url, public_wopi_url,
  disable_certificate_verification, wopi_allowlist into a fact map;
  guard each `config:app:set` and tag `richdocuments:activate-config`
  with `changed_when: false` since it's a discovery refresh.

* drawio: same pattern for DrawioUrl, DrawioTheme, DrawioOffline,
  comparing as strings (occ stores booleans as "1"/"0").

* user_ldap: pre-read `ldap:show-config s01 --output=json`, parse JSON
  defensively (occ logs interleave on stderr), and skip per-key
  `ldap:set-config` calls when the stored value already equals the
  desired one.

* notify_push: skip `notify_push:setup` when the stored base_endpoint
  already matches the computed URL.

* plugins: `app:install`/`app:enable` were treating "already installed/
  enabled" output as a change. Add the negative match to `changed_when`
  so re-runs of a fully-provisioned site report ok rather than changed.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
3855b3e0e7
fix(garage): make bootstrap & provision idempotent across reruns
* bootstrap: `garage layout show` truncates node IDs to 16 chars, but
  the membership check compared against the full hex. After the first
  successful join, subsequent runs no longer found the short ID in
  `layout show` and re-issued `layout assign`, marking the task
  changed every time. Compare against both the truncated and the full
  form so a configured node stays detected. Also tag the read-only
  `garage node id` / `layout show` probes with `changed_when: false`.

* provision keys: the old parser sliced `stdout_lines[1:]` to drop the
  header but missed that INFO log lines and ANSI escapes can interleave
  with table rows. Replace with an explicit `^GK[0-9a-fA-F]+` filter
  after stripping ANSI, so probe-output noise no longer corrupts the
  existing-keys set and triggers spurious `key new` calls.

* provision buckets: same class of fix — match `^[0-9a-f]{16}\s` data
  rows instead of slicing `[2:]`, which broke when the table header
  wasn't exactly two lines.

* provision permissions: pre-read `bucket info` for each (key, bucket)
  pair and only run `bucket allow` when the current `RWO` flag set for
  that key ID doesn't already match the desired permissions. Previously
  `bucket allow` ran unconditionally and reported changed every play.

* `changed_when: false` on all read-only probes (`key list`, `key info`,
  `bucket list`).
2026-05-27 23:12:23 +02:00
Simon Bärlocher
ce50bdb4d3
feat(drawio,garage): optional Authentik ForwardAuth in front of UIs
Add `*_authentik_forward_auth` + `*_authentik_forward_auth_url` knobs to
both roles. When enabled:

* drawio: traefik attaches a ForwardAuth middleware pointing at the
  authentik embedded outpost; unauthenticated requests get redirected
  to log in and downstream sees X-Authentik-* identity headers.

* garage WebUI: same ForwardAuth wiring, and `AUTH_USER_PASS` is dropped
  from the container env so authentik is the only gate. Tasks now key
  the htpasswd hash workflow off `_garage_webui_htpasswd_active`
  (`webui_enabled AND NOT authentik_forward_auth`); when authentik
  fronts the UI we skip hashing entirely. htpasswd hash is also now
  cached on disk and re-verified via `htpasswd -vbB` so unchanged
  passwords stop showing as `changed=true` on every run.

Both knobs default to `false`, preserving existing htpasswd/plain behaviour.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
6411f94cce
feat(authentik): split-horizon host rewrite + proxy-app mode/group bindings
* `authentik_host_rewrite_domains`: extra hostnames that reach the
  authentik container but make it generate URLs (OIDC issuer, reset
  links) as if requested from the canonical `authentik_domains[0]`.
  Each entry gets its own traefik router and a URL-based loadbalancer
  service that disables passHostHeader and pins X-Forwarded-Host via
  middleware, so server-to-server calls on internal FQDNs keep traffic
  in the LAN while the iss claim stays aligned with the public host.
  Uses a network alias on the canonical FQDN so traefik (sharing the
  network) resolves the URL upstream to this very container.

* proxy-app blueprint:
  - `mode` (default `forward_single`) lets callers pick between proxy,
    forward_single and forward_domain providers in one template.
  - `allowed_groups`: when set, emit one PolicyBinding per group on
    the application; authentik OR-evaluates bindings, so users in any
    listed group pass and others are denied.

Existing inventories with an empty list see no behavioural change.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
99d8968a2e
feat(traefik): configurable extra_hosts for container DNS overrides
Add `traefik_extra_hosts` (list of `host:ip`) that maps straight into
the traefik container's compose `extra_hosts`. Needed when a downstream
middleware (e.g. ForwardAuth to authentik on a sibling LAN) has to
resolve a public FQDN to an internal IP because the DMZ doesn't hairpin
the public address back inside.

Empty by default; behaviour unchanged for existing inventories.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
2104e5fe7d
feat: drop blanket recreates, ACME-DNS knobs, notify_push override
- Drop `recreate: always` from collabora/drawio/homarr/opencloud/traefik
  handlers and the authentik_outpost_ldap start task. `up -d` with
  `state: present` already recreates exactly the services whose
  compose definition changed; the blanket recreate was forcing
  restarts even when nothing relevant moved.
- Rewrite the `*_domains` Traefik Host loop to the `Host(\`a\`) ||
  Host(\`b\`)` form across authentik/collabora/garage/nextcloud so the
  rule still matches when traefik can't normalize the comma-form into
  the same canonical shape.
- Traefik: add `traefik_acme_tcp_only` (sets LEGO_EXPERIMENTAL_DNS_TCP_ONLY)
  and `traefik_acme_disable_ans_checks` (disables lego's authoritative-NS
  propagation check) for environments where the DNS path between the
  traefik container and the zone's nameservers is constrained.
- Traefik DMZ collector: two-step merge so a `traefik_dmz_exposed_services`
  entry that sets its own `backend_host` wins over the host fallback;
  lets a route target an internal FQDN covered by the backend cert's
  SANs instead of the raw IP.
- Nextcloud: add `nextcloud_notify_push_domain` override for the
  `occ notify_push:setup` call so the setup check can hit an internal
  FQDN instead of hairpinning through the DMZ. Push router now matches
  every entry in `nextcloud_domains`.
- Nextcloud: also %2F-escape slashes in the postgres user/password
  inside the notify_push DATABASE_URL.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
c3cf779532
feat: domain list refactor + demo-gymburgdorf fixes
- Refactor: collapse `*_domain` + `*_extra_domains` into a single
  `*_domains` list across authentik, collabora, garage and nextcloud
  roles. First entry is the canonical FQDN (used for OVERWRITEHOST,
  BASE_URL, notify_push setup and garage root_domain).
- Authentik blueprint: guard the OAuth sources block so an empty
  `authentik_login_sources` no longer renders an invalid YAML key.
- Nextcloud: introduce `nextcloud_collabora_public_domain` and set
  Collabora's `public_wopi_url` separately from the server-to-server
  `wopi_url` so browsers can reach Collabora via the public name while
  Nextcloud still talks to it on the internal one.
- Nextcloud: URL-encode the postgres user/password in DATABASE_URL.
2026-05-27 23:12:22 +02:00
86 changed files with 3100 additions and 836 deletions

3
.gitignore vendored
View file

@ -3,3 +3,6 @@ __pycache__/
*.pyc
plugins/lookup/__pycache__/
# Local Ansible collection cache (galaxy/collection resolver)
/.ansible/

View file

@ -1,3 +1,68 @@
# Ansible Collection - digitalboard.core
# Ansible Collection digitalboard.core
Documentation for the collection.
This collection bundles the Ansible roles used to deploy the
[Digitalboard](https://git.digitalboard.ch/Digitalboard) platform: a set of
self-hosted, Docker-Compose-based services running behind Traefik, with
single sign-on provided by authentik or Keycloak.
Each role provisions one service (or building block) as a self-contained
Docker Compose stack. Roles are consumed from the deployment repository
[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible),
where inventories and playbooks tie the roles to concrete hosts.
## Roles
| Role | Description |
| --- | --- |
| `base` | Host baseline: Docker, apt packages and convenience tooling on Debian/Ubuntu. |
| `traefik` | Traefik v3 reverse proxy as a public DMZ proxy (file provider) or backend proxy (docker provider). |
| `authentik` | [authentik](https://goauthentik.io) IdP (server + worker + Postgres); resources via blueprints. |
| `authentik_outpost_ldap` | authentik LDAP outpost exposing an LDAP interface for apps that cannot speak OIDC. |
| `keycloak` | [Keycloak](https://www.keycloak.org/) IdP with a PostgreSQL backend. |
| `389ds` | [389 Directory Server](https://www.port389.org/) LDAP directory via Docker Compose. |
| `nextcloud` | [Nextcloud](https://nextcloud.com/) (fpm) + Postgres + Redis, optional Collabora/draw.io/notify_push. |
| `opencloud` | [OpenCloud](https://opencloud.eu/) file platform via Docker Compose. |
| `collabora` | [Collabora Online](https://www.collaboraonline.com/) (CODE), used as the WOPI backend for Nextcloud. |
| `bookstack` | [BookStack](https://www.bookstackapp.com/) wiki (LSIO + MariaDB) with OIDC SSO and daily backups. |
| `drawio` | [draw.io](https://www.drawio.com/) diagram editor, with optional authentik ForwardAuth gating. |
| `homarr` | [Homarr](https://github.com/homarr-labs/homarr) dashboard with seeded admin user and OIDC group. |
| `opnform` | [OpnForm](https://github.com/OpnForm/OpnForm) self-hosted form builder (api + ui + db + redis). |
| `send` | [Send](https://github.com/timvisee/send) (timvisee fork) file sharing with a Redis backend. |
| `garage` | [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible object storage with key/bucket provisioning. |
| `httpbin` | [httpbin](https://httpbin.org/) HTTP request/response testing service for validating Traefik ingress. |
## Usage
Roles are not run from this repository directly. They are consumed from the
deployment repository
[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible),
which holds the inventories, group/host variables and playbooks. See that
repository's `docs/` directory for getting-started instructions, how to run
Ansible and how secrets are managed.
Per-role variables and their defaults are documented in each role's own
`README.md` and `meta/argument_specs.yml`.
## Requirements
- A Debian/Ubuntu target host (the `base` role bootstraps Docker there).
- ansible-core 2.15 or newer on the controller.
- The `community.docker` collection (used by nearly every role) and
`community.general` (used by the `keycloak` role). Both are declared as
`dependencies` in `galaxy.yml` and pulled in automatically when this
collection is installed via `ansible-galaxy`.
The role READMEs use `community.hashi_vault` lookups in their examples to source
secrets from HashiCorp Vault. That is a documented convention, not a hard
dependency of the roles — supply the variables however you prefer.
## Role ordering
Within a play, apply the roles in dependency order: `base` first (Docker and the
host baseline), then `traefik` (the shared reverse proxy and its Docker network),
then the individual service roles (`authentik`, `keycloak`, `nextcloud`, …),
which attach to Traefik's network and expect Docker to be present.
## License
MIT-0. See individual roles for per-role license metadata.

View file

@ -23,12 +23,12 @@ authors:
### OPTIONAL but strongly recommended
# A short summary description of the collection
description: your collection description
description: Ansible roles to deploy the Digitalboard self-hosted service platform (Docker Compose + Traefik + SSO)
# Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only
# accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file'
license:
- GPL-2.0-or-later
- MIT-0
# The path to the license file for the collection. This path is relative to the root of the collection. This key is
# mutually exclusive with 'license'
@ -36,25 +36,36 @@ license_file: ''
# A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character
# requirements as 'namespace' and 'name'
tags: []
tags:
- digitalboard
- docker
- traefik
- sso
- selfhosted
# Collections that this collection requires to be installed for it to be usable. The key of the dict is the
# collection label 'namespace.name'. The value is a version range
# L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version
# range specifiers can be set and are separated by ','
dependencies: {}
dependencies:
# Used by nearly every role: docker_compose_v2, docker_container,
# docker_container_exec, docker_network. Hard runtime dependency.
community.docker: '>=3.0.0'
# Used by the keycloak role (keycloak_realm/client/group/user and
# related modules) in roles/keycloak/tasks/provisioning.yml.
community.general: '>=7.0.0'
# The URL of the originating SCM repository
repository: https://git.digitalboard.ch/Digitalboard/digitalboard.core
# The URL to any online docs
documentation: http://docs.example.com
documentation: https://git.digitalboard.ch/Digitalboard/digitalboard.core
# The URL to the homepage of the collection/project
homepage: http://example.com
homepage: https://git.digitalboard.ch/Digitalboard/digitalboard.core
# The URL to the collection issue tracker
issues: http://example.com/issue/tracker
issues: https://git.digitalboard.ch/Digitalboard/digitalboard.core/issues
# A list of file glob-like patterns used to filter any files or directories that should not be included in the build
# artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This

View file

@ -1,8 +1,9 @@
#SPDX-License-Identifier: MIT-0
---
# Collections must specify a minimum required ansible version to upload
# to galaxy
# requires_ansible: '>=2.9.10'
# to galaxy. Aligned with the highest min_ansible_version declared by the
# roles (the traefik role requires ansible-core 2.15).
requires_ansible: '>=2.15.0'
# Content that Ansible needs to load from another location or that has
# been deprecated/removed

View file

@ -1,31 +1,32 @@
# Collections Plugins Directory
# Collection Plugins — digitalboard.core
This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that
is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that
would contain module utils and modules respectively.
This collection ships a small number of custom plugins that support the roles.
They are addressed by their fully qualified name, `digitalboard.core.<name>`.
Here is an example directory of the majority of plugins currently supported by Ansible:
## Filter plugins (`filter/`)
```
└── plugins
├── action
├── become
├── cache
├── callback
├── cliconf
├── connection
├── filter
├── httpapi
├── inventory
├── lookup
├── module_utils
├── modules
├── netconf
├── shell
├── strategy
├── terminal
├── test
└── vars
`homarr_layout` — computes Homarr dashboard grid layouts (desktop / tablet /
mobile breakpoints) from a list of apps, returning a ready-to-render data
structure for the SQL seed. Used by the `homarr` role.
```yaml
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}"
```
A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.19/plugins/plugins.html).
## Lookup plugins (`lookup/`)
`garage_credentials` — returns S3 credentials (`key_id`, `secret_key`) for a
named Garage key by executing a docker command on the target host. Used to wire
Garage object storage into consuming roles such as `nextcloud`.
```yaml
nextcloud_s3_key: >-
{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['key_id'] }}
nextcloud_s3_secret: >-
{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['secret_key'] }}
```
No other plugin types (modules, action, callback, inventory, etc.) are currently
shipped by this collection.

View file

@ -1,38 +1,43 @@
Role Name
=========
# 389ds
A brief description of the role goes here.
Deploys [389 Directory Server](https://www.port389.org/) (`389ds/dirsrv`)
as an LDAP directory via Docker Compose. After the container starts, the
role creates the configured suffix and a set of base organizational
units (e.g. `users`, `groups`).
Requirements
------------
## Requirements
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
- Docker and Docker Compose on the target host (e.g. via
`digitalboard.core.base`)
- Ansible collection: `community.docker`
Role Variables
--------------
## Role variables
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
| Variable | Default | Description |
| --- | --- | --- |
| `ds389_image` | `docker.io/389ds/dirsrv:3.1` | Container image. |
| `ds389_suffix` | `dc=example,dc=com` | Root suffix of the directory. |
| `ds389_root_dn` | `cn=Directory Manager` | Directory Manager bind DN. |
| `ds389_root_password` | `changeme` | Directory Manager password — **override this**. |
| `ds389_instance_name` | `localhost` | Directory server instance name (slapd config dir). |
| `ds389_hostname` | `389ds` | Container hostname (defaults to `ds389_service_name`). |
| `ds389_backend_network` | `backend` | Docker network LDAP clients connect over (created by Compose). |
| `ds389_ldap_port` | `3389` | Published LDAP port (container port 3389). |
| `ds389_ldaps_port` | `3636` | Published LDAPS port (container port 3636). |
| `ds389_base_ous` | `[users, groups]` | Base OUs created after startup. |
Dependencies
------------
## Example
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
```yaml
- hosts: directory
become: true
roles:
- role: digitalboard.core.389ds
vars:
ds389_suffix: "dc=example,dc=org"
ds389_root_password: "{{ vault_ds389_root_password }}"
```
Example Playbook
----------------
## License
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
MIT-0

View file

@ -1,35 +1,26 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy 389 Directory Server (LDAP) via Docker Compose
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
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.
galaxy_tags:
- 389ds
- ldap
- directory
- docker
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -1,28 +1,136 @@
# Authentik
Deploys Authentik identity provider with Docker Compose.
Deploys [authentik](https://goauthentik.io) (server + worker + Postgres)
as a Docker Compose stack behind Traefik, with all resources provisioned
via templated blueprints.
## What this role does
- Renders the Compose stack with traefik labels and an optional
split-horizon host rewrite (see below)
- Provisions local users, groups, OIDC apps, Proxy/ForwardAuth apps,
LDAP apps and outposts, and Entra ID OAuth sources via blueprints
- Configures the login screen (visible sources, local login fields)
- Supports declarative cleanup via `authentik_removed_*` lists
## Variables
See `defaults/main.yml` for all available variables.
Full spec with types and defaults: `meta/argument_specs.yml`. The most
common overrides:
## Blueprints
### Service
- `authentik_domains` (required, list): FQDNs the router accepts. First
entry is the canonical hostname; further entries cover internal
`*.int.*` names for server-to-server traffic.
- `authentik_secret_key` (required): PG fernet / signing secret.
Generate with `openssl rand -base64 60`.
- `authentik_postgres_password` (required).
- `authentik_image`, `authentik_port`, `authentik_log_level`.
### Split-horizon host rewrite
`authentik_host_rewrite_domains` lists hostnames that should reach the
authentik container but make it generate URLs (OIDC issuer, password
reset links, etc.) as if the request had arrived on
`authentik_domains[0]`.
For each entry the role:
- Creates a dedicated traefik router on that hostname
- Routes it to a URL-based loadbalancer service that disables
`passHostHeader`, so the upstream Host header becomes the canonical
FQDN
- Pins `X-Forwarded-Host` via middleware so the iss claim stays aligned
with the public hostname browsers see
Use case: an internal `auth.int.example.com` keeps server-to-server
traffic in the LAN, but Keycloak/Nextcloud/etc. still receive issuer
URLs matching `auth.example.com`.
### Blueprints
The role renders blueprints for:
- Local users (`authentik_local_users`)
- Groups (`authentik_groups`)
- OIDC applications (`authentik_oidc_apps`)
- Proxy applications (`authentik_proxy_apps`)
- Proxy outposts (`authentik_proxy_outposts`)
- LDAP applications (`authentik_ldap_apps`)
- LDAP outpost (`authentik_ldap_outpost`)
- Entra ID sources (`authentik_entra_sources`)
- Login screen sources (`authentik_login_source_ids`)
- Login-screen source visibility (`authentik_login_sources`)
Secrets are passed via `authentik_blueprint_env` using environment variable references.
Secrets are passed via the `authentik_blueprint_env` env-var indirection
so they never land in rendered blueprint YAML on disk.
#### Proxy apps: mode and group restrictions
Each entry in `authentik_proxy_apps` supports:
- `mode` (default `forward_single`): one of `proxy`, `forward_single`,
`forward_domain`
- `allowed_groups`: when set, a `PolicyBinding` is emitted per group on
the application. authentik OR-evaluates bindings, so users in any
listed group pass and users in none are denied.
Example:
```yaml
authentik_proxy_apps:
- slug: drawio
name: drawio
external_host: "https://drawio.example.com"
mode: forward_single
allowed_groups:
- drawio-users
- admins
```
## Removing resources
To remove resources from Authentik, move slugs to the removal lists:
Move slugs from the active list to the matching removal list:
- `authentik_removed_oidc_apps`
- `authentik_removed_proxy_apps`
- `authentik_removed_local_users`
After confirming deletion, remove the slug from the list.
After authentik has applied the deletion blueprint, remove the slug
from the list to keep state clean.
## Dependencies
- Run `digitalboard.core.base` first (Docker) and have the `community.docker`
collection installed; the role drives the stack via
`community.docker.docker_compose_v2`.
- Traefik network (`authentik_traefik_network`, default `proxy`) must exist
beforehand (e.g. created by the traefik role); it is referenced as an
external network in the Compose file.
- Internal backend network (`authentik_backend_network`, default `backend`).
## Example playbook
```yaml
- hosts: identity_servers
roles:
- role: digitalboard.core.authentik
vars:
authentik_domains:
- "auth.example.com"
- "auth.int.example.com"
authentik_host_rewrite_domains:
- "auth.int.example.com"
authentik_secret_key: "{{ vault_authentik_secret_key }}"
authentik_postgres_password: "{{ vault_authentik_pg_password }}"
authentik_proxy_apps:
- slug: drawio
name: drawio
external_host: "https://drawio.example.com"
mode: forward_single
allowed_groups: [drawio-users]
```
## License
MIT-0

View file

@ -12,7 +12,20 @@ authentik_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ authentik_servic
authentik_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ authentik_service_name }}"
# Authentik service configuration
authentik_domain: "authentik.local.test"
# FQDNs the authentik router accepts. The first entry is the canonical
# domain; further entries cover internal *.int.* names used for
# server-to-server traffic so backend calls don't hairpin via DMZ.
authentik_domains:
- "authentik.local.test"
# Hostnames that should reach authentik but make it generate URLs (OIDC
# issuer, password reset links, etc.) as if requested from the canonical
# `authentik_domains[0]` instead. Used for split-horizon setups where an
# internal FQDN (e.g. `auth.int.example.com`) keeps server-to-server
# traffic in the LAN but the iss claim must still match the public
# hostname that browsers see. Traefik handles each entry via a separate
# router that rewrites the Host header before forwarding to authentik.
authentik_host_rewrite_domains: []
authentik_image: "ghcr.io/goauthentik/server:2026.2.2"
authentik_port: 9000
authentik_secret_key: "changeme-generate-a-random-string"

View file

@ -0,0 +1,193 @@
---
argument_specs:
main:
short_description: Deploy authentik (server + worker + Postgres) via Docker Compose.
description:
- Renders a Compose stack for authentik with traefik labels, optional
TLS and a configurable split-horizon host-rewrite that keeps the OIDC
issuer URL on the canonical public hostname even when traffic enters
on an internal FQDN.
- Provisions resources through templated blueprints
(local users, groups, OIDC/Proxy/LDAP apps, outposts, OAuth sources).
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
authentik_service_name:
type: str
default: authentik
authentik_docker_compose_dir:
type: path
description: Defaults to C({{ docker_compose_base_dir }}/{{ authentik_service_name }}).
authentik_docker_volume_dir:
type: path
description: Defaults to C({{ docker_volume_base_dir }}/{{ authentik_service_name }}).
authentik_domains:
type: list
elements: str
required: true
description:
- FQDNs the authentik router accepts. The first entry is the
canonical (public) hostname and is used for the network alias,
the X-Forwarded-Host rewrite target, and as the default OIDC
issuer. Further entries cover internal C(*.int.*) names used
for server-to-server traffic.
authentik_host_rewrite_domains:
type: list
elements: str
default: []
description:
- Hostnames that should reach authentik but make it generate URLs
(OIDC issuer, password reset links, etc.) as if the request had
arrived on C(authentik_domains[0]).
- Each entry gets its own traefik router and a URL-based
loadbalancer service that disables passHostHeader and pins
X-Forwarded-Host via middleware. Used for split-horizon setups
where the LAN keeps server-to-server traffic but the iss claim
must match the public hostname browsers see.
authentik_image:
type: str
default: ghcr.io/goauthentik/server:2026.2.2
authentik_port:
type: int
default: 9000
authentik_secret_key:
type: str
required: true
description: PG fernet key / signing secret. Generate with C(openssl rand -base64 60).
authentik_postgres_image:
type: str
default: postgres:16-alpine
authentik_postgres_db:
type: str
default: authentik
authentik_postgres_user:
type: str
default: authentik
authentik_postgres_password:
type: str
required: true
authentik_traefik_network:
type: str
default: proxy
authentik_backend_network:
type: str
default: backend
authentik_use_ssl:
type: bool
default: true
authentik_log_level:
type: str
choices: [trace, debug, info, warning, error]
default: info
authentik_error_reporting_enabled:
type: bool
default: false
authentik_proxy_apps:
type: list
elements: dict
default: []
description:
- Proxy/ForwardAuth applications rendered via the
C(blueprint-proxy-app.yaml.j2) template.
options:
slug:
type: str
required: true
name:
type: str
required: true
internal_host:
type: str
description: Required when C(mode=proxy).
external_host:
type: str
required: true
mode:
type: str
choices: [proxy, forward_single, forward_domain]
default: forward_single
description:
- "C(proxy): the outpost itself proxies traffic to internal_host."
- "C(forward_single): a single app behind an external reverse
proxy via ForwardAuth."
- "C(forward_domain): wildcard mode — one provider guards every
host on a cookie domain."
allowed_groups:
type: list
elements: str
description:
- If set, PolicyBindings are emitted (one per group, OR-evaluated).
Users in none of the listed groups are denied.
skip_path_regex:
type: str
flows:
type: dict
description: Authentication / authorization / invalidation flow slugs.
authentik_proxy_outposts:
type: list
elements: dict
default: []
authentik_ldap_apps:
type: list
elements: dict
default: []
authentik_ldap_outpost:
type: dict
default: {}
authentik_oidc_apps:
type: list
elements: dict
default: []
authentik_entra_sources:
type: list
elements: dict
default: []
authentik_login_sources:
type: list
elements: dict
default: []
authentik_identification_stage_name:
type: str
default: default-authentication-identification
authentik_login_user_fields:
type: list
elements: str
choices: [username, email, upn]
default: [username, email]
description: Local login fields shown on the login screen. Empty list hides local login.
authentik_groups:
type: list
elements: dict
default: []
authentik_local_users:
type: list
elements: dict
default: []
authentik_removed_oidc_apps:
type: list
elements: str
default: []
description: OIDC application slugs scheduled for deletion.
authentik_removed_proxy_apps:
type: list
elements: str
default: []
authentik_removed_local_users:
type: list
elements: str
default: []

View file

@ -1,35 +1,28 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy authentik (server + worker + Postgres) via Docker Compose with blueprint-provisioned resources
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
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.
galaxy_tags:
- authentik
- oidc
- sso
- idp
- docker
- traefik
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -16,8 +16,10 @@ entries:
{% for field in authentik_login_user_fields %}
- {{ field }}
{% endfor %}
{% if authentik_login_sources %}
# OAuth/social login sources (use !Find to reference sources from other blueprints)
sources:
{% for src in authentik_login_sources %}
- !Find [authentik_sources_oauth.oauthsource, [slug, {{ src.slug }}]]
{% endfor %}
{% endif %}

View file

@ -20,6 +20,16 @@ entries:
internal_host: "{{ item.internal_host }}"
external_host: "{{ item.external_host }}"
{# Provider mode controls how authentik treats the proxy app:
- proxy : the outpost itself proxies traffic to internal_host
- forward_single : a single app behind an external reverse proxy
(traefik forwardauth talks to authentik per-domain)
- forward_domain : wildcard mode — one provider guards every host on a
cookie domain; configure forward_auth_mode=domain on
the outpost in that case. Default to forward_single
since that's the common ForwardAuth-with-traefik
pattern. #}
mode: {{ item.mode | default('forward_single') }}
{% if item.skip_path_regex is defined and item.skip_path_regex|length > 0 %}
skip_path_regex: |
@ -34,3 +44,20 @@ entries:
name: "{{ item.name | default(item.slug) }}"
slug: {{ item.slug }}
provider: !KeyOf proxy-provider-{{ item.slug }}
{% if item.allowed_groups is defined and item.allowed_groups | length > 0 %}
{# Restrict access to listed groups: one PolicyBinding per group, all bound
to the application. Authentik treats multiple bindings on the same target
as OR (a user matching any binding passes), and a request from a user in
none of the bound groups is denied. #}
{% for group_name in item.allowed_groups %}
- model: authentik_policies.policybinding
identifiers:
target: !KeyOf app-{{ item.slug }}
order: {{ loop.index0 }}
group: !Find [authentik_core.group, [name, "{{ group_name }}"]]
attrs:
enabled: true
negate: false
{% endfor %}
{% endif %}

View file

@ -43,19 +43,58 @@ services:
postgres:
condition: service_healthy
networks:
- {{ authentik_backend_network }}
- {{ authentik_traefik_network }}
{{ authentik_backend_network }}: {}
# No alias for the public FQDN here: that would shadow `/etc/hosts`
# pins (extra_hosts) in other containers sharing this network and
# break OIDC discovery for Node-based clients (c-ares-based
# resolvers consult Docker DNS before /etc/hosts). The URL-based
# service below addresses this container by its compose service
# name `server`, which Docker exposes as an alias on every network
# the container joins.
{{ authentik_traefik_network }}: {}
labels:
- traefik.enable=true
- traefik.docker.network={{ authentik_traefik_network }}
- traefik.http.routers.{{ authentik_service_name }}.rule=Host(`{{ authentik_domain }}`)
- traefik.http.routers.{{ authentik_service_name }}.rule={% for d in authentik_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
- traefik.http.routers.{{ authentik_service_name }}.service={{ authentik_service_name }}
{% if authentik_use_ssl %}
- traefik.http.routers.{{ authentik_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ authentik_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ authentik_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ authentik_service_name }}.entrypoints=web
{% endif %}
- traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }}
{% if authentik_host_rewrite_domains | length > 0 %}
# Server-to-server entry: a separate service points at this very
# container by its compose service name `server` and disables
# passHostHeader so the upstream Host header becomes
# `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer URLs
# from X-Forwarded-Host (not Host), so we also pin that header via
# middleware. Together this keeps the iss claim aligned with the
# public hostname browsers see during login, even when the request
# itself arrived on an internal *.int.* FQDN.
- traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://server:{{ authentik_port }}
- traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.passhostheader=false
- traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }}
{% for d in authentik_host_rewrite_domains %}
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.rule=Host(`{{ d }}`)
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.priority=100
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.service={{ authentik_service_name }}-rewrite
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.middlewares={{ authentik_service_name }}-xfh-rewrite
{% if authentik_use_ssl %}
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.entrypoints=websecure
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.entrypoints=web
{% endif %}
{% endfor %}
{% endif %}
worker:
image: {{ authentik_image }}

View file

@ -1,38 +1,44 @@
Role Name
=========
# authentik_outpost_ldap
A brief description of the role goes here.
Deploys an [authentik](https://goauthentik.io) LDAP outpost via Docker
Compose. The outpost exposes an LDAP interface backed by authentik, so
applications that cannot speak OIDC (e.g. Nextcloud or OpenCloud LDAP
backends) can still authenticate against the central IdP.
Requirements
------------
The outpost connects back to an authentik server using an outpost token
issued in the authentik admin interface. The image version must match
the authentik server version.
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
## Requirements
Role Variables
--------------
- Docker and Docker Compose on the target host (e.g. via
`digitalboard.core.base`)
- Ansible collection: `community.docker`
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
## Role variables
Dependencies
------------
| Variable | Default | Description |
| --- | --- | --- |
| `authentik_outpost_ldap_image` | `ghcr.io/goauthentik/ldap:2026.2.2` | Outpost image (match the server version). |
| `authentik_outpost_ldap_host` | `https://authentik.local.test` | URL of the authentik server. |
| `authentik_outpost_ldap_token` | `changeme` | Outpost token — **override this**. |
| `authentik_outpost_ldap_insecure` | `"true"` | Skip TLS verification toward the authentik server. |
| `authentik_outpost_ldap_network` | `ldap` | Docker network LDAP clients connect over (created by the role). |
| `authentik_outpost_ldap_authentik_network` | _unset_ | Optional extra external network to the authentik server. |
| `authentik_outpost_ldap_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. |
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
## Example
Example Playbook
----------------
```yaml
- hosts: directory
become: true
roles:
- role: digitalboard.core.authentik_outpost_ldap
vars:
authentik_outpost_ldap_host: "https://auth.example.com"
authentik_outpost_ldap_token: "{{ vault_authentik_ldap_outpost_token }}"
```
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
## License
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
MIT-0

View file

@ -1,35 +1,27 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy an authentik LDAP outpost via Docker Compose for applications that cannot use OIDC
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
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.
galaxy_tags:
- authentik
- ldap
- outpost
- sso
- docker
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -1,38 +1,45 @@
Role Name
=========
# base
A brief description of the role goes here.
Host baseline for the Digitalboard platform. Installs Docker (engine,
CLI, containerd, buildx, compose plugin) and a small set of apt and
convenience packages on Debian/Ubuntu, and sets the shared directory
layout every other role builds on.
Requirements
------------
This role is intended to run first on every host, before any
service role.
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
## What it does
Role Variables
--------------
- Installs Docker prerequisites (`apt-transport-https`, `ca-certificates`,
`curl`, `gnupg`, `lsb-release`, `apache2-utils` for `htpasswd`) plus
convenience packages (`htop`, `ncdu`, `vim`) and Docker itself
(`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`,
`docker-compose-plugin`).
- Optionally configures Docker registry mirrors via `/etc/docker/daemon.json`.
- Starts and enables the Docker service and writes a custom `/etc/motd`.
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
This role defines the shared directory-layout variables
(`docker_compose_base_dir`, `docker_volume_base_dir`) that every service
role consumes, but the per-service subdirectories are created by the
respective service roles, not here.
Dependencies
------------
## Role variables
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
| Variable | Default | Description |
| --- | --- | --- |
| `docker_compose_base_dir` | `/etc/docker/compose` | Root directory for per-service Compose projects. |
| `docker_volume_base_dir` | `/srv/data` | Root directory for per-service persistent volumes. |
| `docker_registry_mirrors` | `[]` | Optional list of registry mirror URLs; empty disables mirrors. |
Example Playbook
----------------
## Example
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
```yaml
- hosts: all
become: true
roles:
- digitalboard.core.base
```
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
## License
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
MIT-0

View file

@ -1,35 +1,25 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Host baseline — install Docker, required apt packages and convenience tooling on Debian/Ubuntu
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
min_ansible_version: 2.1
# 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.
galaxy_tags:
- base
- docker
- bootstrap
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -22,9 +22,14 @@ The role asserts these are set; the play fails fast if any is empty:
| `bookstack_db_root_password` | MariaDB root password |
| `bookstack_db_password` | MariaDB user password |
| `bookstack_admin_password` | Initial local admin password |
| `bookstack_oidc_client_id` | Entra ID App Registration ID (if OIDC on) |
| `bookstack_oidc_client_secret` | Entra ID client secret (if OIDC on) |
| `bookstack_entra_tenant_id` | Entra tenant UUID (if OIDC on) |
| `bookstack_oidc_client_id` | OIDC client ID (if OIDC on) |
| `bookstack_oidc_client_secret` | OIDC client secret (if OIDC on) |
When OIDC is on, the role also asserts that `bookstack_oidc_issuer`
resolves to a concrete URL. For Entra ID this means setting
`bookstack_entra_tenant_id` (the default issuer interpolates it; an unset
tenant leaves `//v2.0` and fails the assert). For other IdPs (Authentik,
Keycloak) set `bookstack_oidc_issuer` directly instead.
Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit
real secrets.
@ -34,6 +39,10 @@ real secrets.
See `defaults/main.yml`. Frequently overridden:
- `bookstack_domain`, `bookstack_base_url`
- `bookstack_extra_domains` (extra Host-rule hostnames, e.g. an internal
`*.int.*` FQDN for a DMZ reverseproxy)
- `bookstack_extra_hosts` (container `/etc/hosts` overrides for
split-horizon IdP access; entries as `host:ip`)
- `bookstack_image`, `bookstack_db_image` (pin in production)
- `bookstack_oidc_enabled` (set `false` to disable OIDC entirely)
- `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP)
@ -142,4 +151,4 @@ Restore procedure:
## License
MIT
MIT-0

View file

@ -16,6 +16,14 @@ bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup"
# Service configuration
bookstack_domain: "wiki.local.test"
# Additional hostnames the bookstack router answers on (e.g. an internal
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
bookstack_extra_domains: []
# Container-level /etc/hosts overrides — useful in split-horizon setups
# where the BookStack container needs to reach an IdP's public FQDN
# (used in the OIDC `iss` claim) over the LAN rather than via the DMZ.
bookstack_extra_hosts: []
bookstack_base_url: "https://{{ bookstack_domain }}"
# Images — pin via inventory in production

View file

@ -37,6 +37,24 @@ argument_specs:
type: str
default: wiki.local.test
description: Hostname used in the Traefik Host rule.
bookstack_extra_domains:
type: list
elements: str
default: []
description:
- Additional hostnames the Traefik router answers on, OR-combined
with C(bookstack_domain). Useful for an internal C(*.int.*) FQDN
so a DMZ reverseproxy can reach a backend hostname covered by the
cert.
bookstack_extra_hosts:
type: list
elements: str
default: []
description:
- Container-level C(/etc/hosts) overrides (Compose C(extra_hosts)
entries, C("host:ip")). Useful in split-horizon setups where the
BookStack container must reach an IdP's public FQDN (used in the
OIDC C(iss) claim) over the LAN rather than via the DMZ.
bookstack_base_url:
type: str
description: Defaults to C("https://{{ bookstack_domain }}").

View file

@ -2,7 +2,7 @@ galaxy_info:
author: digitalboard
description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik
company: digitalboard
license: MIT
license: MIT-0
min_ansible_version: "2.14"

View file

@ -14,7 +14,13 @@
- bookstack_admin_password | length > 0
- (not bookstack_oidc_enabled) or (bookstack_oidc_client_id | length > 0)
- (not bookstack_oidc_enabled) or (bookstack_oidc_client_secret | length > 0)
- (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0)
# Issuer URL must resolve to something concrete. The Entra default
# interpolates bookstack_entra_tenant_id; an unset tenant leaves
# "//v2.0" in the URL. Allow non-Entra IdPs (Authentik, Keycloak)
# that override bookstack_oidc_issuer directly.
- (not bookstack_oidc_enabled) or
(bookstack_oidc_issuer | length > 0 and
'//v2.0' not in bookstack_oidc_issuer)
fail_msg: >-
One or more required secrets are unset. Provide them via OpenBao
lookup, Ansible Vault or --extra-vars. See README for the full list.

View file

@ -45,13 +45,19 @@ services:
networks:
- {{ bookstack_traefik_network }}
- internal
{% if bookstack_extra_hosts | length > 0 %}
extra_hosts:
{% for host in bookstack_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
depends_on:
{{ bookstack_service_name }}-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network={{ bookstack_traefik_network }}"
- "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)"
- "traefik.http.routers.{{ bookstack_service_name }}.rule={% set _all_domains = [bookstack_domain] + (bookstack_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}"
- "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure"
- "traefik.http.routers.{{ bookstack_service_name }}.tls=true"
- "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}"

View file

@ -1,38 +1,42 @@
Role Name
=========
# collabora
A brief description of the role goes here.
Deploys [Collabora Online](https://www.collaboraonline.com/) (CODE,
`collabora/code`) via Docker Compose behind Traefik. Collabora is the
WOPI backend that renders office documents for Nextcloud and OpenCloud.
Requirements
------------
The role templates `coolwsd.xml` to declare which WOPI hosts may call
Collabora and which origins may embed it in an iframe.
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
## Role variables
Role Variables
--------------
| Variable | Default | Description |
| --- | --- | --- |
| `collabora_domains` | `[office.local.test]` | FQDNs the router accepts; first is canonical. |
| `collabora_image` | `collabora/code:latest` | Container image. |
| `collabora_port` | `9980` | Container port Traefik forwards to. |
| `collabora_traefik_network` | `proxy` | Docker network shared with Traefik. |
| `collabora_use_ssl` | `true` | Enable the TLS resolver on the router. |
| `collabora_ssl_verification` | `true` | Verify TLS on WOPI callbacks (false for self-signed). |
| `collabora_allowed_domains` | `[nextcloud.local.test]` | WOPI hosts allowed to call Collabora (regex). |
| `collabora_frame_ancestors` | `[nextcloud.local.test]` | Origins allowed to embed Collabora in an iframe. |
| `collabora_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. |
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
## Example
Dependencies
------------
```yaml
- hosts: services
become: true
roles:
- role: digitalboard.core.collabora
vars:
collabora_domains:
- "office.example.com"
collabora_allowed_domains:
- "cloud.example.com"
collabora_frame_ancestors:
- "cloud.example.com"
```
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
## License
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
MIT-0

View file

@ -12,7 +12,11 @@ collabora_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ collabora_servic
collabora_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ collabora_service_name }}"
# Service configuration
collabora_domain: "office.local.test"
# FQDNs the collabora router accepts. The first entry is the canonical
# domain; further entries cover internal *.int.* names used for
# server-to-server WOPI discovery.
collabora_domains:
- "office.local.test"
collabora_image: "collabora/code:latest"
collabora_port: 9980
collabora_extra_hosts: []

View file

@ -5,4 +5,4 @@
- name: restart collabora
community.docker.docker_compose_v2:
project_src: "{{ collabora_docker_compose_dir }}"
state: restarted
state: present

View file

@ -1,35 +1,27 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy Collabora Online (CODE) as a WOPI backend via Docker Compose behind Traefik
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
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.
galaxy_tags:
- collabora
- office
- wopi
- nextcloud
- docker
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -20,11 +20,14 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network={{ collabora_traefik_network }}
- traefik.http.routers.{{ collabora_service_name }}.rule=Host(`{{ collabora_domain }}`)
- traefik.http.routers.{{ collabora_service_name }}.rule={% for d in collabora_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
- traefik.http.services.{{ collabora_service_name }}.loadbalancer.server.port={{ collabora_port }}
{% if collabora_use_ssl %}
- traefik.http.routers.{{ collabora_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ collabora_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ collabora_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ collabora_service_name }}.entrypoints=web
{% endif %}

View file

@ -1,38 +1,60 @@
Role Name
=========
# Drawio
A brief description of the role goes here.
Ansible role to deploy [draw.io](https://www.drawio.com/) (the
self-hosted `jgraph/drawio` container) via Docker Compose behind
Traefik, with optional authentik ForwardAuth gating.
Requirements
------------
## Requirements
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
- Docker and Docker Compose installed on the target host
- Ansible collection: `community.docker`
- Traefik with a shared `drawio_traefik_network` (default `proxy`)
- For ForwardAuth: a reachable authentik embedded outpost endpoint
Role Variables
--------------
## Role variables
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
Full spec with types and defaults: `meta/argument_specs.yml`. The most
common overrides:
Dependencies
------------
### Service
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
- `drawio_domain`: canonical hostname used in the traefik Host rule
(default `drawio.local.test`).
- `drawio_extra_domains`: additional hostnames the same container
should answer on (e.g. an internal `*.int.*` FQDN so a DMZ proxy
can reach drawio via a backend hostname).
- `drawio_image`, `drawio_port`, `drawio_use_ssl`.
Example Playbook
----------------
### Authentik ForwardAuth
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- `drawio_authentik_forward_auth`: set to `true` to gate the editor
behind authentik.
- `drawio_authentik_forward_auth_url`: full URL of the embedded
outpost ForwardAuth endpoint, e.g.
`https://auth.example.com/outpost.goauthentik.io/auth/traefik`.
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
When enabled, traefik redirects unauthenticated requests to authentik
for login and forwards the resulting `X-Authentik-*` identity headers
downstream.
License
-------
## Dependencies
BSD
- Traefik network (`drawio_traefik_network`, default `proxy`)
- Optional: authentik with a Proxy/ForwardAuth provider for drawio
(see the `authentik` role's `authentik_proxy_apps`).
Author Information
------------------
## Example playbook
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
```yaml
- hosts: app_servers
roles:
- role: digitalboard.core.drawio
vars:
drawio_domain: "drawio.example.com"
drawio_authentik_forward_auth: true
drawio_authentik_forward_auth_url: "https://auth.example.com/outpost.goauthentik.io/auth/traefik"
```
## License
MIT-0

View file

@ -11,10 +11,21 @@ drawio_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ drawio_service_name
# Service configuration
drawio_domain: "drawio.local.test"
# Additional hostnames the same drawio container should answer on
# (e.g. an internal *.int.* FQDN so a DMZ reverseproxy can reach
# drawio via a backend hostname covered by the local traefik cert).
drawio_extra_domains: []
drawio_image: "jgraph/drawio:latest"
drawio_port: 8080
drawio_extra_hosts: []
# Traefik configuration
drawio_traefik_network: "proxy"
drawio_use_ssl: true
drawio_use_ssl: true
# Optional Authentik ForwardAuth (set to true and provide the URL to gate
# drawio behind an authentik proxy provider). Expects the authentik
# embedded outpost to expose the /outpost.goauthentik.io/auth/traefik
# endpoint on the configured URL (typically the public auth.* FQDN).
drawio_authentik_forward_auth: false
drawio_authentik_forward_auth_url: ""

View file

@ -5,4 +5,4 @@
- name: restart drawio
community.docker.docker_compose_v2:
project_src: "{{ drawio_docker_compose_dir }}"
state: restarted
state: present

View file

@ -0,0 +1,64 @@
---
argument_specs:
main:
short_description: Deploy draw.io diagram editor via Docker Compose behind Traefik.
description:
- Renders a Compose stack for jgraph/drawio with traefik labels, optional
TLS and optional authentik ForwardAuth gating.
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
drawio_service_name:
type: str
default: drawio
drawio_docker_compose_dir:
type: path
description: Defaults to C({{ docker_compose_base_dir }}/{{ drawio_service_name }}).
drawio_domain:
type: str
default: drawio.local.test
description: Canonical hostname used in the traefik Host rule.
drawio_extra_domains:
type: list
elements: str
default: []
description:
- Additional hostnames the same drawio container should answer on,
e.g. an internal C(*.int.*) FQDN so a DMZ reverse-proxy can reach
drawio via a backend hostname covered by the local traefik cert.
drawio_image:
type: str
default: jgraph/drawio:latest
drawio_port:
type: int
default: 8080
drawio_extra_hosts:
type: list
elements: str
default: []
description: C(extra_hosts) entries injected into the container (Docker C(host:ip) syntax).
drawio_traefik_network:
type: str
default: proxy
drawio_use_ssl:
type: bool
default: true
drawio_authentik_forward_auth:
type: bool
default: false
description:
- When true, traefik attaches a ForwardAuth middleware pointing at
the authentik embedded outpost. Unauthenticated requests are
redirected to authentik for login and the resulting
C(X-Authentik-*) identity headers are forwarded downstream.
drawio_authentik_forward_auth_url:
type: str
default: ''
description:
- URL of the authentik ForwardAuth endpoint, typically
C(https://auth.example.com/outpost.goauthentik.io/auth/traefik).
Required when C(drawio_authentik_forward_auth=true).

View file

@ -1,35 +1,26 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy the draw.io diagram editor via Docker Compose behind Traefik, with optional authentik ForwardAuth
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
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.
galaxy_tags:
- drawio
- diagrams
- docker
- traefik
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -14,14 +14,26 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network={{ drawio_traefik_network }}
- traefik.http.routers.{{ drawio_service_name }}.rule=Host(`{{ drawio_domain }}`)
- traefik.http.routers.{{ drawio_service_name }}.rule={% set _all_domains = [drawio_domain] + (drawio_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
- traefik.http.services.{{ drawio_service_name }}.loadbalancer.server.port={{ drawio_port }}
{% if drawio_use_ssl %}
- traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ drawio_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ drawio_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ drawio_service_name }}.entrypoints=web
{% endif %}
{% if drawio_authentik_forward_auth | default(false) %}
# ForwardAuth via the authentik embedded outpost. Unauthenticated
# requests get redirected to authentik to log in; authentik then
# sets X-Authentik-* headers traefik forwards downstream.
- traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.address={{ drawio_authentik_forward_auth_url }}
- traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.trustForwardHeader=true
- traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version
- traefik.http.routers.{{ drawio_service_name }}.middlewares={{ drawio_service_name }}-authentik
{% endif %}
networks:
{{ drawio_traefik_network }}:

View file

@ -1,113 +1,118 @@
Garage
======
# Garage
Ansible role to deploy Garage S3-compatible object storage using Docker Compose.
Ansible role to deploy [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible
object storage via Docker Compose, with declarative key/bucket
provisioning and an optional WebUI behind htpasswd or authentik
ForwardAuth.
Requirements
------------
## Requirements
- Docker and Docker Compose installed on the target host
- Ansible collection: `community.docker`
- Traefik reverse proxy (for external access)
- `htpasswd` (from `apache2-utils` / `httpd-tools`) when the WebUI is
enabled and authentik ForwardAuth is *not* used
- Traefik with a shared `garage_traefik_network` (default `proxy`)
Role Variables
--------------
## Role variables
Key variables defined in `defaults/main.yml`:
Full spec with types and defaults: `meta/argument_specs.yml`. The most
common overrides:
**Base Configuration:**
- `docker_compose_base_dir`: Base directory for Docker Compose files (default: `/etc/docker/compose`)
- `docker_volume_base_dir`: Base directory for Docker volumes (default: `/srv/data`)
### Service
**Garage Configuration:**
- `garage_service_name`: Service name (default: `garage`)
- `garage_image`: Garage Docker image (default: `dxflrs/garage:v2.1.0`)
- `garage_s3_domain`: Domain for S3 API endpoint (default: `storage.local.test`)
- `garage_web_domain`: Domain for S3 web endpoint (default: `web.storage.local.test`)
- `garage_webui_domain`: Domain for web console (default: `console.storage.local.test`)
- `garage_s3_domains`: FQDNs the S3 router accepts. The first entry is the
canonical hostname; `garage.toml` derives the virtual-hosted-style S3
`root_domain` from it as `.s3.<first-entry>` (so buckets resolve under
`<bucket>.s3.<first-entry>`).
- `garage_web_domain`, `garage_webui_domain`: separate hostnames for
the S3-website endpoint and the console.
- `garage_image`, `garage_replication_factor`, `garage_db_engine`,
`garage_s3_region`.
**Garage Storage Configuration:**
- `garage_replication_factor`: Replication factor (default: `1`)
- `garage_compression_level`: Compression level (default: `1`)
- `garage_db_engine`: Database engine (default: `lmdb`)
- `garage_s3_region`: S3 region (default: `us-east-1`)
### Required secrets
**Garage Ports:**
- `garage_s3_api_port`: S3 API port (default: `3900`)
- `garage_s3_web_port`: S3 web port (default: `3902`)
- `garage_admin_port`: Admin API port (default: `3903`)
- `garage_rpc_port`: RPC port (default: `3901`)
Generate with `openssl rand -hex 32` (32 bytes / 64 hex chars):
**Garage Security:**
- `garage_rpc_secret`: RPC secret for node communication
- `garage_admin_token`: Admin API token
- `garage_metrics_token`: Metrics API token
- `garage_rpc_secret`: node-to-node RPC secret
- `garage_admin_token`: admin API token
- `garage_metrics_token`: metrics endpoint token
**Garage WebUI Configuration:**
- `garage_webui_enabled`: Enable web UI (default: `true`)
- `garage_webui_image`: WebUI Docker image (default: `khairul169/garage-webui:latest`)
- `garage_webui_port`: WebUI port (default: `3909`)
- `garage_webui_username`: WebUI username (default: `admin`)
- `garage_webui_password`: WebUI password in plaintext (default: `admin`)
### WebUI authentication
**Traefik Configuration:**
- `garage_traefik_network`: Traefik network name (default: `proxy`)
- `garage_internal_network`: Internal network name (default: `internal`)
- `garage_use_ssl`: Enable SSL (default: `true`)
Three modes:
Dependencies
------------
1. **htpasswd** (default): `garage_webui_username` / `garage_webui_password`
in plaintext. The role hashes the password with
`htpasswd -nbBC 10`, persists the hash on disk, and re-verifies with
`htpasswd -vbB` so unchanged passwords don't churn the play.
2. **authentik ForwardAuth**: set
`garage_webui_authentik_forward_auth: true` and
`garage_webui_authentik_forward_auth_url:
"https://auth.example.com/outpost.goauthentik.io/auth/traefik"`.
`AUTH_USER_PASS` is dropped from the container env so authentik is
the only gate.
3. **Disabled**: `garage_webui_enabled: false`.
This role requires:
- Traefik reverse proxy to be configured and the `proxy` network to be created
- `htpasswd` utility (from `apache2-utils` package) for generating bcrypt password hashes
### Layout bootstrap
Example Playbook
----------------
Setting `garage_bootstrap_enabled: true` runs the bootstrap task, which
joins the local node to the layout (`zone: garage_bootstrap_zone`,
capacity: `garage_bootstrap_capacity`) on the first run. The check
tolerates the 16-char truncation that `garage layout show` performs.
### Declarative S3 keys and buckets
```yaml
garage_s3_keys:
- name: nextcloud
buckets:
- name: nextcloud-data
permissions: [read, write]
- name: backup
buckets:
- name: restic-prod
permissions: [read, write, owner]
```
The role:
- Lists existing keys (`garage key list`), creates missing ones
- Lists existing buckets (`garage bucket list`), creates missing ones
- Reads current permissions via `garage bucket info` and runs
`garage bucket allow` only when the current RWO flags for the key
don't already match the desired permissions
`stdout` parsing is hardened against ANSI escapes and interleaved INFO
log lines, so probe noise no longer produces spurious changes.
## Dependencies
- Traefik network (`garage_traefik_network`, default `proxy`)
- Internal network (`garage_internal_network`, default `internal`)
## Example playbook
```yaml
- hosts: storage_servers
roles:
- role: garage
- role: digitalboard.core.garage
vars:
garage_s3_domain: "storage.example.com"
garage_rpc_secret: "your-secure-rpc-secret"
garage_admin_token: "your-admin-token"
garage_webui_enabled: true
garage_webui_username: "admin"
garage_webui_password: "secure-password"
garage_s3_domains:
- "storage.example.com"
- "storage.int.example.com"
garage_rpc_secret: "{{ vault_garage_rpc_secret }}"
garage_admin_token: "{{ vault_garage_admin_token }}"
garage_metrics_token: "{{ vault_garage_metrics_token }}"
garage_bootstrap_enabled: true
garage_webui_authentik_forward_auth: true
garage_webui_authentik_forward_auth_url: "https://auth.example.com/outpost.goauthentik.io/auth/traefik"
garage_s3_keys:
- name: nextcloud
buckets:
- name: nextcloud-data
permissions: [read, write]
```
**Note:** The WebUI password is specified in plaintext and will be automatically hashed using bcrypt during deployment. The role uses `htpasswd` to generate a secure bcrypt hash that is then properly escaped for use in Docker Compose.
## License
Post-Installation
-----------------
After deployment, you need to configure the Garage cluster:
1. Connect to the node and get the node ID:
```bash
docker exec -ti garage /garage node id
```
2. Configure the node layout:
```bash
docker exec -ti garage /garage layout assign -z dc1 -c 1G <node-id>
docker exec -ti garage /garage layout apply --version 1
```
3. Create a key for S3 access:
```bash
docker exec -ti garage /garage key create my-key
```
4. Create a bucket:
```bash
docker exec -ti garage /garage bucket create my-bucket
docker exec -ti garage /garage bucket allow my-bucket --read --write --key my-key
```
License
-------
MIT-0
MIT-0

View file

@ -13,7 +13,12 @@ garage_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ garage_service_name }
# Garage service configuration
garage_image: "dxflrs/garage:v2.1.0"
garage_s3_domain: "storage.local.test"
# FQDNs the garage S3 router accepts. The first entry is the canonical
# domain; garage.toml derives the virtual-hosted-style S3 root_domain
# from it as ".s3.<first-entry>"; further entries cover internal
# *.int.* names.
garage_s3_domains:
- "storage.local.test"
garage_web_domain: "web.storage.local.test"
garage_webui_domain: "console.storage.local.test"
@ -21,10 +26,20 @@ garage_webui_domain: "console.storage.local.test"
garage_webui_enabled: true
garage_webui_image: "khairul169/garage-webui:latest"
garage_webui_port: 3909
# WebUI basic auth credentials (plaintext, will be hashed automatically)
# WebUI basic auth credentials (plaintext, will be hashed automatically).
# Ignored when garage_webui_authentik_forward_auth is true — in that case
# authentik handles authentication via the ForwardAuth middleware below.
garage_webui_username: "admin"
garage_webui_password: "admin"
# Optional Authentik ForwardAuth in front of the WebUI. When true:
# - the AUTH_USER_PASS env-var is dropped from the container so htpasswd
# isn't enforced; authentik is the only gate.
# - traefik attaches a ForwardAuth middleware pointing at the URL below.
# Leave false to keep classic htpasswd protection.
garage_webui_authentik_forward_auth: false
garage_webui_authentik_forward_auth_url: ""
# Garage ports
garage_s3_api_port: 3900
garage_s3_web_port: 3902

View file

@ -0,0 +1,169 @@
---
argument_specs:
main:
short_description: Deploy Garage S3-compatible object storage via Docker Compose.
description:
- Renders a Compose stack for Garage with traefik labels, configures the
node layout on first run, and (optionally) provisions S3 keys, buckets
and per-key permissions declaratively.
- The optional WebUI can be protected by classic htpasswd or by
authentik ForwardAuth.
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
garage_service_name:
type: str
default: garage
garage_docker_compose_dir:
type: path
description: Defaults to C({{ docker_compose_base_dir }}/{{ garage_service_name }}).
garage_docker_volume_dir:
type: path
description: Defaults to C({{ docker_volume_base_dir }}/{{ garage_service_name }}).
garage_image:
type: str
default: dxflrs/garage:v2.1.0
garage_s3_domains:
type: list
elements: str
default: ['storage.local.test']
description:
- FQDNs the garage S3 router accepts. The first entry is the
canonical domain; C(garage.toml) derives the virtual-hosted-style
S3 C(root_domain) from it as C(.s3.<first-entry>). Further entries
cover internal C(*.int.*) names.
garage_web_domain:
type: str
default: web.storage.local.test
description: Hostname serving the S3-website endpoint.
garage_webui_domain:
type: str
default: console.storage.local.test
description: Hostname serving the WebUI console.
garage_webui_enabled:
type: bool
default: true
garage_webui_image:
type: str
default: khairul169/garage-webui:latest
garage_webui_port:
type: int
default: 3909
garage_webui_username:
type: str
default: admin
description: htpasswd username. Ignored when C(garage_webui_authentik_forward_auth=true).
garage_webui_password:
type: str
default: admin
description:
- Plaintext password; hashed with C(htpasswd -nbBC 10) and persisted
on disk so re-runs don't churn. Ignored when authentik ForwardAuth
is enabled.
garage_webui_authentik_forward_auth:
type: bool
default: false
description:
- When true the C(AUTH_USER_PASS) env-var is dropped from the WebUI
container and traefik attaches a ForwardAuth middleware pointing
at the URL below. authentik is then the only gate; htpasswd is
disabled.
garage_webui_authentik_forward_auth_url:
type: str
default: ''
description:
- Required when C(garage_webui_authentik_forward_auth=true).
Typically C(https://auth.example.com/outpost.goauthentik.io/auth/traefik).
garage_s3_api_port:
type: int
default: 3900
garage_s3_web_port:
type: int
default: 3902
garage_admin_port:
type: int
default: 3903
garage_rpc_port:
type: int
default: 3901
garage_replication_factor:
type: int
default: 1
garage_compression_level:
type: int
default: 1
garage_db_engine:
type: str
choices: [lmdb, sqlite, sled]
default: lmdb
garage_s3_region:
type: str
default: us-east-1
garage_rpc_secret:
type: str
required: true
description: Hex secret for node-to-node RPC. Generate with C(openssl rand -hex 32).
garage_admin_token:
type: str
required: true
garage_metrics_token:
type: str
required: true
garage_traefik_network:
type: str
default: proxy
garage_internal_network:
type: str
default: internal
garage_use_ssl:
type: bool
default: true
garage_bootstrap_enabled:
type: bool
default: false
description: When true the bootstrap task ensures the node is in the layout.
garage_bootstrap_zone:
type: str
default: dc1
description: Zone label assigned during layout bootstrap.
garage_bootstrap_capacity:
type: str
default: 1G
description: Capacity string passed to C(garage layout assign -c).
garage_s3_keys:
type: list
elements: dict
default: []
description:
- Declarative key + bucket + permission provisioning. The role
creates missing keys, missing buckets, and runs C(bucket allow)
only when the current RWO flags for a given key don't match.
options:
name:
type: str
required: true
buckets:
type: list
elements: dict
description: Buckets this key gets access to.
options:
name:
type: str
required: true
permissions:
type: list
elements: str
choices: [read, write, owner]
required: true

View file

@ -1,35 +1,27 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy Garage S3-compatible object storage via Docker Compose, with declarative key/bucket provisioning
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
min_ansible_version: 2.1
# 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.
galaxy_tags:
- garage
- s3
- storage
- object-storage
- docker
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -7,21 +7,27 @@
container: "{{ garage_service_name }}"
command: /garage node id -q
register: _garage_node_id
changed_when: false
- name: Extract short node ID
ansible.builtin.set_fact:
_garage_node_id_short: "{{ _garage_node_id.stdout.split('@')[0] }}"
- name: Extract truncated node ID (first 16 chars, matches `garage layout show` output)
ansible.builtin.set_fact:
_garage_node_id_truncated: "{{ _garage_node_id_short[:16] }}"
- name: Check if node layout is configured
community.docker.docker_container_exec:
container: "{{ garage_service_name }}"
command: /garage layout show
register: _garage_layout_show
failed_when: false
changed_when: false
- name: Check if node is in layout
ansible.builtin.set_fact:
_node_in_layout: "{{ _garage_node_id_short in _garage_layout_show.stdout }}"
_node_in_layout: "{{ (_garage_node_id_truncated in _garage_layout_show.stdout) or (_garage_node_id_short in _garage_layout_show.stdout) }}"
- name: Configure garage node layout
community.docker.docker_container_exec:

View file

@ -26,12 +26,77 @@
dest: "{{ garage_docker_compose_dir }}/garage.toml"
mode: '0644'
- name: Generate bcrypt hash for webui password using htpasswd
ansible.builtin.shell: |
htpasswd -nbBC 10 "{{ garage_webui_username }}" "{{ garage_webui_password }}"
register: _garage_webui_password_hash
- name: Set webui htpasswd activation fact
ansible.builtin.set_fact:
# htpasswd only runs when the WebUI is enabled AND authentik ForwardAuth
# is not handling authentication. When authentik is in front, the
# compose template drops AUTH_USER_PASS so no hash is needed.
_garage_webui_htpasswd_active: >-
{{
garage_webui_enabled
and not (garage_webui_authentik_forward_auth | default(false))
}}
- name: Read cached webui htpasswd hash
ansible.builtin.slurp:
src: "{{ garage_docker_compose_dir }}/webui.htpasswd"
register: _garage_webui_htpasswd_cached
failed_when: false
changed_when: false
when: garage_webui_enabled
when: _garage_webui_htpasswd_active
- name: Verify cached webui htpasswd hash still matches password
ansible.builtin.command:
argv:
- htpasswd
- -vbB
- "{{ garage_docker_compose_dir }}/webui.htpasswd"
- "{{ garage_webui_username }}"
- "{{ garage_webui_password }}"
register: _garage_webui_htpasswd_verify
failed_when: false
changed_when: false
no_log: true
when:
- _garage_webui_htpasswd_active
- _garage_webui_htpasswd_cached.content is defined
- name: Generate bcrypt hash for webui password using htpasswd
ansible.builtin.command:
argv:
- htpasswd
- -nbBC
- "10"
- "{{ garage_webui_username }}"
- "{{ garage_webui_password }}"
register: _garage_webui_password_hash_new
changed_when: true
when:
- _garage_webui_htpasswd_active
- (_garage_webui_htpasswd_cached.content is not defined)
or (_garage_webui_htpasswd_verify.rc | default(1) != 0)
- name: Persist webui htpasswd hash on disk
ansible.builtin.copy:
content: "{{ _garage_webui_password_hash_new.stdout }}\n"
dest: "{{ garage_docker_compose_dir }}/webui.htpasswd"
mode: '0600'
when:
- _garage_webui_htpasswd_active
- _garage_webui_password_hash_new is changed
- name: Load current webui htpasswd hash
ansible.builtin.slurp:
src: "{{ garage_docker_compose_dir }}/webui.htpasswd"
register: _garage_webui_htpasswd_current
changed_when: false
when: _garage_webui_htpasswd_active
- name: Expose current webui htpasswd hash to template
ansible.builtin.set_fact:
_garage_webui_password_hash:
stdout: "{{ (_garage_webui_htpasswd_current.content | b64decode).strip() }}"
when: _garage_webui_htpasswd_active
- name: Create docker-compose file for garage
template:

View file

@ -4,11 +4,17 @@
container: "{{ garage_service_name }}"
command: /garage key list
register: _existing_keys_output
changed_when: false
when: garage_s3_keys | length > 0
- name: Parse existing key names
ansible.builtin.set_fact:
_existing_keys: "{{ _existing_keys_output.stdout_lines[1:] | select('match', '^GK') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+)\\s+.*$', '\\1') | list }}"
# `garage key list` columns: ID Created Name Expiration.
# Data rows begin with a GK<hex> key ID; header is "ID Created ..."
# and INFO log lines may interleave on stderr (kept separate by
# docker_container_exec). Strip ANSI escapes defensively, filter to
# GK-prefixed rows, then take the 3rd whitespace-separated field.
_existing_keys: "{{ _existing_keys_output.stdout_lines | map('regex_replace', '\\x1b\\[[0-9;]*m', '') | select('match', '^GK[0-9a-fA-F]+') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+).*$', '\\1') | list }}"
when: garage_s3_keys | length > 0
- name: Create S3 keys
@ -27,6 +33,7 @@
command: /garage key info {{ item.name }}
loop: "{{ garage_s3_keys }}"
register: _key_info_results
changed_when: false
when: garage_s3_keys | length > 0
- name: Extract key IDs from info
@ -42,11 +49,21 @@
container: "{{ garage_service_name }}"
command: /garage bucket list
register: _existing_buckets_output
changed_when: false
when: garage_s3_keys | length > 0
- name: Parse existing bucket names
ansible.builtin.set_fact:
_existing_buckets: "{{ _existing_buckets_output.stdout_lines[2:] | map('split') | map('first') | list }}"
# `garage bucket list` columns: ID Created Global aliases Local aliases
# Data rows start with a hex bucket ID; filter to those and take the
# third whitespace-separated field (the global alias = bucket name).
_existing_buckets: >-
{{
_existing_buckets_output.stdout_lines
| select('match', '^[0-9a-f]{16}\\s')
| map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+).*$', '\\1')
| list
}}
when: garage_s3_keys | length > 0
- name: Get unique bucket names
@ -64,12 +81,37 @@
- item not in _existing_buckets
failed_when: false
- name: Get current bucket permissions
community.docker.docker_container_exec:
container: "{{ garage_service_name }}"
command: /garage bucket info {{ item.1.name }}
loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}"
loop_control:
label: "{{ item.1.name }}"
register: _bucket_info_results
changed_when: false
when: garage_s3_keys | length > 0
- name: Set bucket permissions using key IDs
community.docker.docker_container_exec:
container: "{{ garage_service_name }}"
command: /garage bucket allow {{ item.1.name }} {% for perm in item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.0.name] }}
loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}"
when: garage_s3_keys | length > 0
command: /garage bucket allow {{ item.item.1.name }} {% for perm in item.item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.item.0.name] }}
loop: "{{ _bucket_info_results.results }}"
loop_control:
label: "{{ item.item.1.name }} -> {{ item.item.0.name }}"
when:
- garage_s3_keys | length > 0
- >-
(item.stdout | regex_search(
'(?m)^\s*' ~ _wanted_flags ~ '\s+' ~ _key_id_map[item.item.0.name]
)) is none
vars:
_wanted_flags: >-
{{
('R' if 'read' in item.item.1.permissions else '-')
~ ('W' if 'write' in item.item.1.permissions else '-')
~ ('O' if 'owner' in item.item.1.permissions else '-')
}}
# Export key credentials for use by other roles
- name: Get detailed key information for all keys
@ -78,6 +120,7 @@
command: /garage key info {{ item.name }} --show-secret
loop: "{{ garage_s3_keys }}"
register: _key_details_results
changed_when: false
when: garage_s3_keys | length > 0
- name: Build garage S3 credentials map

View file

@ -14,10 +14,13 @@ services:
- traefik.enable=true
- traefik.docker.network={{ garage_traefik_network }}
# S3 API endpoint
- traefik.http.routers.{{ garage_service_name }}.rule=Host(`{{ garage_s3_domain }}`)
- traefik.http.routers.{{ garage_service_name }}.rule={% for d in garage_s3_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
{% if garage_use_ssl %}
- traefik.http.routers.{{ garage_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ garage_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ garage_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ garage_service_name }}.entrypoints=web
{% endif %}
@ -35,7 +38,9 @@ services:
environment:
API_BASE_URL: "http://{{ garage_service_name }}:{{ garage_admin_port }}"
S3_ENDPOINT_URL: "http://{{ garage_service_name }}:{{ garage_s3_api_port }}"
{% if not (garage_webui_authentik_forward_auth | default(false)) %}
AUTH_USER_PASS: '{{ _garage_webui_password_hash.stdout | replace("$", "$$") }}'
{% endif %}
volumes:
- {{ garage_docker_compose_dir }}/garage.toml:/etc/garage.toml:ro
networks:
@ -48,12 +53,25 @@ services:
{% if garage_use_ssl %}
- traefik.http.routers.{{ garage_service_name }}-console.entrypoints=websecure
- traefik.http.routers.{{ garage_service_name }}-console.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ garage_service_name }}-console.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ garage_service_name }}-console.entrypoints=web
{% endif %}
- traefik.http.routers.{{ garage_service_name }}-console.service={{ garage_service_name }}-console
- traefik.http.routers.{{ garage_service_name }}-console.priority=10
- traefik.http.services.{{ garage_service_name }}-console.loadbalancer.server.port={{ garage_webui_port }}
{% if garage_webui_authentik_forward_auth | default(false) %}
# ForwardAuth via the authentik embedded outpost. Unauthenticated
# requests are redirected to authentik; authentik then forwards
# X-Authentik-* identity headers downstream. htpasswd is disabled
# in the env block above so authentik is the only gate.
- traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.address={{ garage_webui_authentik_forward_auth_url }}
- traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.trustForwardHeader=true
- traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version
- traefik.http.routers.{{ garage_service_name }}-console.middlewares={{ garage_service_name }}-console-authentik
{% endif %}
{% endif %}
networks:

View file

@ -14,7 +14,7 @@ rpc_secret = "{{ garage_rpc_secret }}"
[s3_api]
s3_region = "{{ garage_s3_region }}"
api_bind_addr = "[::]:{{ garage_s3_api_port }}"
root_domain = ".s3.{{ garage_s3_domain }}"
root_domain = ".s3.{{ garage_s3_domains[0] }}"
[s3_web]
bind_addr = "[::]:{{ garage_s3_web_port }}"

View file

@ -36,8 +36,10 @@ secrets to version control.**
| `homarr_admin_password` | strong password | `openssl rand -base64 24` |
| `homarr_oidc_client_secret` | from your identity provider | — |
The `assert` task at the top of the role will fail fast if the encryption
key is missing or malformed.
`homarr_oidc_client_secret` is only required when `oidc` is in
`homarr_auth_providers`; the role asserts it then. The encryption key is
always required — the `assert` task at the top of the role fails fast if it
is missing or malformed.
## Configurable variables
@ -46,6 +48,8 @@ See `defaults/main.yml` for the full list. Most useful overrides:
| Variable | Default | Purpose |
|---|---|---|
| `homarr_domain` | `homarr.local.test` | Traefik Host rule |
| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN |
| `homarr_extra_hosts` | `[]` | Container `/etc/hosts` overrides (`host:ip`) — pin IdP FQDN to LAN IP |
| `homarr_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL |
| `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both |
| `homarr_oidc_issuer` | empty | Identity provider issuer URL |
@ -112,7 +116,7 @@ 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 }}"
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}"
```
This produces a `homarr_layout` fact with two keys, both consumed by
@ -120,14 +124,14 @@ This produces a `homarr_layout` fact with two keys, both consumed by
| Key | Shape | Purpose |
|---|---|---|
| `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` |
| `apps` | list, same order as `homarr_apps` | each entry gains `desktop`/`tablet`/`mobile` 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) }}
{{ homarr_apps | digitalboard.core.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
@ -240,4 +244,8 @@ and lowercase are accepted.
**App tiles overlap.** Check `homarr_apps` for duplicate `id` values.
The role validates this, but if you bypass the check, the seed will
still run and Homarr will display only one of the duplicates.
still run and Homarr will display only one of the duplicates.
## License
MIT-0

View file

@ -15,6 +15,14 @@ homarr_db: "{{ homarr_appdata_dir }}/db/db.sqlite"
# Service configuration
homarr_domain: "homarr.local.test"
# Additional hostnames the homarr router answers on (e.g. an internal
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
homarr_extra_domains: []
# Extra /etc/hosts entries inside the homarr container (format "host:ip").
# Used to pin the IdP's public FQDN to a LAN IP so OIDC discovery stays
# in-network while the issuer URL matches what browsers see.
homarr_extra_hosts: []
homarr_image: "ghcr.io/homarr-labs/homarr:latest"
homarr_port: 7575
homarr_use_docker: false

View file

@ -5,4 +5,4 @@
- name: restart homarr
community.docker.docker_compose_v2:
project_src: "{{ homarr_docker_compose_dir }}"
state: restarted
state: present

View file

@ -1,35 +1,27 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy the Homarr dashboard via Docker Compose behind Traefik, with seeded admin user and OIDC group
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
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.
galaxy_tags:
- homarr
- dashboard
- oidc
- docker
- traefik
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -112,19 +112,17 @@
# =====================================================================
- name: Generate bcrypt hash for admin password
ansible.builtin.shell:
cmd: python3 -c "import bcrypt, sys; print(bcrypt.hashpw(sys.stdin.read().encode(), bcrypt.gensalt(rounds=10)).decode())"
stdin: "{{ homarr_admin_password }}"
stdin_add_newline: false
delegate_to: localhost
become: false
register: bcrypt_result
changed_when: false
no_log: true
- name: Set bcrypt hash fact
ansible.builtin.set_fact:
homarr_bcrypt_hash: "{{ bcrypt_result.stdout }}"
# Deterministic salt derived from the password's SHA-256 digest so the
# hash stays stable across runs (idempotent — no spurious template
# changes / container restarts when the password is unchanged). The
# bcrypt salt alphabet is [./A-Za-z0-9]; the digest's hex chars are
# a strict subset, so we just take the first 22.
homarr_bcrypt_hash: >-
{{ homarr_admin_password
| password_hash('bcrypt', rounds=10,
salt=(homarr_admin_password
| hash('sha256'))[:22]) }}
no_log: true
# =====================================================================
@ -161,4 +159,4 @@
register: seed_result
changed_when: seed_result.rc == 0
when: admin_exists.stdout == ""
notify: restart homarr
notify: restart homarr

View file

@ -26,13 +26,22 @@ services:
AUTH_OIDC_AUTO_LOGIN: "{{ homarr_oidc_auto_login | default('false') }}"
networks:
- {{ homarr_traefik_network }}
{% if homarr_extra_hosts | default([]) | length > 0 %}
extra_hosts:
{% for h in homarr_extra_hosts %}
- "{{ h }}"
{% endfor %}
{% endif %}
labels:
- traefik.enable=true
- traefik.docker.network={{ homarr_traefik_network }}
- traefik.http.routers.homarr.rule=Host(`{{ homarr_domain }}`)
- traefik.http.routers.homarr.rule={% set _all_domains = [homarr_domain] + (homarr_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
{% if homarr_use_ssl %}
- traefik.http.routers.homarr.entrypoints=websecure
- traefik.http.routers.homarr.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.homarr.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.homarr.entrypoints=web
{% endif %}

View file

@ -1,38 +1,30 @@
Role Name
=========
# httpbin
A brief description of the role goes here.
Deploys [httpbin](https://httpbin.org/) (`kennethreitz/httpbin`) via
Docker Compose behind Traefik. Useful as a throwaway endpoint to verify
that the Traefik ingress path, TLS and routing work end to end.
Requirements
------------
## Role variables
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
| Variable | Default | Description |
| --- | --- | --- |
| `httpbin_domain` | `httpbin.local.test` | FQDN the Traefik router matches. |
| `httpbin_image` | `kennethreitz/httpbin` | Container image. |
| `httpbin_port` | `80` | Container port Traefik forwards to. |
| `httpbin_traefik_network` | `proxy` | Docker network shared with Traefik. |
| `httpbin_use_ssl` | `true` | Route via the `websecure` entrypoint with `tls=true` (otherwise `web`). |
Role Variables
--------------
## Example
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
```yaml
- hosts: services
become: true
roles:
- role: digitalboard.core.httpbin
vars:
httpbin_domain: "httpbin.example.com"
```
Dependencies
------------
## License
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
MIT-0

View file

@ -1,35 +1,27 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy httpbin HTTP request/response testing service via Docker Compose behind Traefik
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
min_ansible_version: 2.1
# 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.
galaxy_tags:
- httpbin
- testing
- debug
- docker
- traefik
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -1,65 +1,119 @@
Keycloak
=========
# Keycloak
Ansible role to deploy Keycloak with PostgreSQL database using Docker Compose.
Ansible role to deploy Keycloak with a PostgreSQL backend via Docker
Compose, published behind Traefik. Optionally provisions realm resources
(groups, users, OIDC clients, identity providers, LDAP user federations)
through the `community.general` Keycloak modules.
Requirements
------------
## Requirements
- Docker and Docker Compose installed on the target host
- Ansible collection: `community.docker`
- Traefik reverse proxy (for external access)
- Docker and Docker Compose on the target host (e.g. via
`digitalboard.core.base`)
- Ansible collections: `community.docker`, and `community.general` when
`keycloak_provisioning_enabled` is true
- Traefik reverse proxy with the `proxy` network already created (for
external access)
Role Variables
--------------
## Role variables
Key variables defined in `defaults/main.yml`:
Key variables from `defaults/main.yml`:
**Base Configuration:**
- `docker_compose_base_dir`: Base directory for Docker Compose files (default: `/etc/docker/compose`)
- `docker_volume_base_dir`: Base directory for Docker volumes (default: `/srv/data`)
### Base configuration
**Keycloak Configuration:**
- `keycloak_service_name`: Service name (default: `keycloak`)
- `keycloak_domain`: Domain name for Keycloak (default: `auth.digitalboard.ch`)
- `keycloak_image`: Keycloak Docker image (default: `quay.io/keycloak/keycloak:24.0.1`)
- `keycloak_port`: Internal Keycloak port (default: `8080`)
- `keycloak_admin_user`: Admin username (default: `admin`)
- `keycloak_admin_password`: Admin password (default: `changeme`)
- `keycloak_log_level`: Log level (default: `INFO`)
- `keycloak_proxy_mode`: Proxy mode (default: `edge`)
| Variable | Default | Description |
| --- | --- | --- |
| `docker_compose_base_dir` | `/etc/docker/compose` | Base dir for Compose projects. |
| `docker_volume_base_dir` | `/srv/data` | Base dir for persistent volumes. |
| `keycloak_service_name` | `keycloak` | Compose/service name; builds the per-service paths. |
**PostgreSQL Configuration:**
- `keycloak_postgres_image`: PostgreSQL Docker image (default: `postgres:15`)
- `keycloak_postgres_db`: Database name (default: `keycloak`)
- `keycloak_postgres_user`: Database user (default: `keycloak`)
- `keycloak_postgres_password`: Database password (default: `changeme`)
### Keycloak
**Traefik Configuration:**
- `keycloak_traefik_network`: Traefik network name (default: `proxy`)
- `keycloak_backend_network`: Backend network name (default: `backend`)
- `keycloak_use_ssl`: Enable SSL (default: `true`)
- `keycloak_cert_resolver`: Certificate resolver name (default: `dns`)
| Variable | Default | Description |
| --- | --- | --- |
| `keycloak_domain` | `keycloak.local.test` | Host rule and `KC_HOSTNAME`. |
| `keycloak_image` | `quay.io/keycloak/keycloak:24.0.1` | Keycloak image. |
| `keycloak_port` | `8080` | Internal HTTP port advertised to Traefik. |
| `keycloak_admin_user` | `admin` | Bootstrap admin user. |
| `keycloak_admin_password` | `changeme` | Admin password — **override this**. |
| `keycloak_log_level` | `INFO` | `KC_LOG_LEVEL`. |
| `keycloak_proxy_mode` | `edge` | `KC_PROXY` mode. |
| `keycloak_gzip_enabled` | `false` | Toggle Keycloak GZIP response encoding. |
| `keycloak_truststore_certificates` | `[]` | Host PEM paths mounted into the truststore (`KC_TRUSTSTORE_PATHS`). |
| `keycloak_extra_hosts` | `[]` | Extra `host:ip` entries for the container. |
Dependencies
------------
### PostgreSQL
This role requires the Traefik reverse proxy to be configured and the `proxy` network to be created.
| Variable | Default | Description |
| --- | --- | --- |
| `keycloak_postgres_image` | `postgres:15` | PostgreSQL image. |
| `keycloak_postgres_db` | `keycloak` | Database name. |
| `keycloak_postgres_user` | `keycloak` | Database user. |
| `keycloak_postgres_password` | `changeme` | Database password — **override this**. |
Example Playbook
----------------
### Traefik
| Variable | Default | Description |
| --- | --- | --- |
| `keycloak_traefik_network` | `proxy` | External Traefik network. |
| `keycloak_backend_network` | `backend` | Internal network to PostgreSQL. |
| `keycloak_use_ssl` | `true` | Route on `websecure` with `tls=true` instead of `web`. |
TLS is requested from Traefik via `tls=true`; the role does not set a
certificate resolver, so Traefik issues/serves the certificate according
to its own configuration.
### Provisioning (optional)
Provisioning runs only when `keycloak_provisioning_enabled` is true. The
tasks wait for the `/health/ready` endpoint and then call the
`community.general.keycloak_*` modules, delegated to `localhost` against
`keycloak_auth_url` (derived from `keycloak_use_ssl` + `keycloak_domain`).
| Variable | Default | Description |
| --- | --- | --- |
| `keycloak_provisioning_enabled` | `false` | Enable realm provisioning. |
| `keycloak_realm` | `default` | Target realm; created unless `master`. |
| `keycloak_realm_display_name` | `Default Realm` | Realm display name. |
| `keycloak_auth_url` | derived | API base URL for provisioning. |
| `keycloak_groups` | `[]` | Groups to create. |
| `keycloak_local_users` | `[]` | Local users to create. |
| `keycloak_oidc_clients` | `[]` | OIDC clients to create. |
| `keycloak_identity_providers` | `[]` | Identity providers (e.g. Entra ID). |
| `keycloak_user_federations` | `[]` | LDAP user federations. |
| `keycloak_removed_users` | `[]` | Usernames to delete. |
| `keycloak_removed_groups` | `[]` | Group names to delete. |
| `keycloak_removed_clients` | `[]` | Client IDs to delete. |
| `keycloak_removed_identity_providers` | `[]` | IdP aliases to delete. |
| `keycloak_removed_user_federations` | `[]` | Federation names to delete. |
See `defaults/main.yml` for the full entry shape of each list.
## Dependencies
This role requires the Traefik reverse proxy to be configured and the
`proxy` network to be created beforehand (it is referenced as an external
network in the Compose file). The `backend` network is created by the
Compose project itself.
## Example playbook
```yaml
- hosts: backend_servers
roles:
- role: keycloak
- role: digitalboard.core.keycloak
vars:
keycloak_domain: "auth.example.com"
keycloak_admin_password: "secure_password"
keycloak_postgres_password: "secure_db_password"
keycloak_admin_password: "{{ vault_keycloak_admin_password }}"
keycloak_postgres_password: "{{ vault_keycloak_pg_password }}"
keycloak_provisioning_enabled: true
keycloak_oidc_clients:
- client_id: nextcloud
name: "Nextcloud"
client_secret: "{{ vault_nextcloud_client_secret }}"
redirect_uris:
- "https://nextcloud.example.com/apps/user_oidc/code"
```
License
-------
## License
MIT-0

View file

@ -1,35 +1,27 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy Keycloak with a PostgreSQL backend via Docker Compose behind Traefik
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
min_ansible_version: 2.1
# 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.
galaxy_tags:
- keycloak
- oidc
- sso
- docker
- traefik
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

124
roles/nextcloud/README.md Normal file
View file

@ -0,0 +1,124 @@
# Nextcloud
Ansible role to deploy [Nextcloud](https://nextcloud.com/) (fpm) with
Postgres and Redis via Docker Compose, optional Collabora WOPI
integration, optional draw.io integration, optional notify_push
companion, optional S3 primary storage, plus OIDC and LDAP user
backends.
## What this role does
- Renders the Compose stack with traefik labels and TLS
- Installs and enables a configurable list of Nextcloud apps idempotently
- Configures Collabora (richdocuments), draw.io, OIDC providers and
LDAP via `occ` — every setting is read first and only written when
the stored value differs, so re-runs don't churn
- Sets up notify_push (when enabled)
- Applies an in-container PHP source workaround for the upstream
`UserConfig::getValueBool` TypeError (nextcloud/server#59629, fixed in
master via PR #59646 with no stable33 backport before 33.0.4).
Idempotent via grep guard; remove the patch task once
`nextcloud_image` is >= 33.0.4.
## Requirements
- Docker and Docker Compose installed on the target host
- Ansible collection: `community.docker`
- Traefik with a shared `nextcloud_traefik_network` (default `proxy`)
## Role variables
Full spec with types and defaults: `meta/argument_specs.yml`. The most
common overrides:
### Service
- `nextcloud_domains`: FQDNs the router accepts. First entry is the
canonical hostname (used for `OVERWRITEHOST` and notify_push setup).
Further entries cover internal `*.int.*` names so Collabora's WOPI
callback hits the instance on a name with a valid cert.
- `nextcloud_admin_password`, `nextcloud_postgres_password` (required).
- `nextcloud_memory_limit_mb`, `nextcloud_upload_limit_mb`.
### Collabora
- `nextcloud_enable_collabora`: toggle integration with a separately
deployed Collabora server (see the `collabora` role).
- `nextcloud_collabora_domain`: server-to-server hostname.
- `nextcloud_collabora_public_domain` (optional): browser-facing
hostname when split-horizon uses different names.
### Draw.io
- `nextcloud_enable_drawio`: enable the `integration_drawio` app.
- `nextcloud_drawio_url`: public draw.io URL.
- `nextcloud_drawio_theme`, `nextcloud_drawio_offline`.
### Notify push
- `nextcloud_enable_notify_push`: deploy the notify_push companion.
- `nextcloud_notify_push_domain` (optional): override the hostname
used by `occ notify_push:setup` to avoid hairpinning through the DMZ.
### S3 primary storage
Set `nextcloud_use_s3_storage: true` plus the `nextcloud_s3_*` block to
point Nextcloud at an external S3-compatible store (e.g. Garage, MinIO).
### OIDC
`nextcloud_oidc_providers` is a list of OIDC providers registered with
`user_oidc`. Required fields per entry: `identifier`, `display_name`,
`client_id`, `client_secret`, `discovery_url`.
### LDAP
Set `nextcloud_ldap_enabled: true` and provide `nextcloud_ldap_config`
as a dict of `occ ldap:set-config s01 KEY VALUE` pairs. The role reads
the current LDAP config via `occ ldap:show-config s01 --output=json`
and only calls `ldap:set-config` for keys whose stored value differs.
## Dependencies
- Traefik network (`nextcloud_traefik_network`, default `proxy`)
- Optional: `collabora`, `drawio`, `garage` roles for the corresponding
integrations
- Optional: an OIDC provider (Keycloak, authentik) reachable from
Nextcloud and a 389ds LDAP server when using `user_ldap`
## Example playbook
```yaml
- hosts: app_servers
roles:
- role: digitalboard.core.nextcloud
vars:
nextcloud_domains:
- "cloud.example.com"
- "cloud.int.example.com"
nextcloud_admin_password: "{{ vault_nextcloud_admin_password }}"
nextcloud_postgres_password: "{{ vault_nextcloud_pg_password }}"
nextcloud_enable_collabora: true
nextcloud_collabora_domain: "office.int.example.com"
nextcloud_collabora_public_domain: "office.example.com"
nextcloud_enable_notify_push: true
nextcloud_notify_push_domain: "cloud.int.example.com"
nextcloud_oidc_providers:
- identifier: authentik
display_name: "Login with Authentik"
client_id: nextcloud
client_secret: "{{ vault_nextcloud_oidc_secret }}"
discovery_url: "https://auth.example.com/application/o/nextcloud/.well-known/openid-configuration"
mapping:
uid: preferred_username
display_name: name
email: email
groups: groups
```
## License
MIT-0

View file

@ -9,7 +9,12 @@ nextcloud_service_name: nextcloud
nextcloud_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ nextcloud_service_name }}"
nextcloud_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ nextcloud_service_name }}"
nextcloud_domain: "nextcloud.local.test"
# FQDNs the nextcloud router accepts. The first entry is the canonical
# domain (used for OVERWRITEHOST and the notify_push setup); further
# entries cover internal *.int.* names so collabora's WOPI callback
# hits us on a name with a valid cert.
nextcloud_domains:
- "nextcloud.local.test"
nextcloud_image: "nextcloud:fpm"
nextcloud_redis_image: "redis:latest"
nextcloud_port: 80
@ -60,6 +65,12 @@ nextcloud_trusted_proxies: "172.16.0.0/12"
# File locking and real-time push notifications
nextcloud_enable_notify_push: false
nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1"
# Domain used when calling `occ notify_push:setup`. Defaults to the
# first nextcloud_domains entry (the canonical public name). Override
# with an internal FQDN to avoid hairpinning the setup check through
# the DMZ; the FQDN must also be in nextcloud_domains so the push
# router matches it.
# nextcloud_notify_push_domain: "cloud.int.example.com"
# Nextcloud Talk: register external HPB signaling + TURN + STUN
# Set to true to run tasks/talk.yml after Nextcloud is up.

View file

@ -0,0 +1,253 @@
---
argument_specs:
main:
short_description: Deploy Nextcloud (fpm) + Redis + Postgres via Docker Compose.
description:
- Renders a Compose stack for Nextcloud with traefik labels, optional
Collabora WOPI integration, optional draw.io integration, optional
notify_push companion, optional S3 primary storage, OIDC providers
and LDAP user backend.
- "All C(occ)-driven configuration tasks are idempotent: each setting
is read with C(config:app:get) (or C(ldap:show-config)) first and
only written when the stored value differs."
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
nextcloud_service_name:
type: str
default: nextcloud
nextcloud_docker_compose_dir:
type: path
nextcloud_docker_volume_dir:
type: path
nextcloud_domains:
type: list
elements: str
default: ['nextcloud.local.test']
description:
- FQDNs the nextcloud router accepts. The first entry is the
canonical domain (used for C(OVERWRITEHOST) and the
C(notify_push) setup). Further entries cover internal C(*.int.*)
names so Collabora's WOPI callback hits the instance on a name
with a valid certificate.
nextcloud_image:
type: str
default: nextcloud:fpm
nextcloud_redis_image:
type: str
default: redis:latest
nextcloud_port:
type: int
default: 80
nextcloud_extra_hosts:
type: list
elements: str
default: []
nextcloud_extra_networks:
type: list
elements: str
default: []
nextcloud_allow_local_remote_servers:
type: bool
default: false
description: Allow requests to local network from Nextcloud (dev only).
nextcloud_postgres_image:
type: str
default: postgres:15
nextcloud_postgres_db:
type: str
default: nextcloud
nextcloud_postgres_user:
type: str
default: nextcloud
nextcloud_postgres_password:
type: str
required: true
nextcloud_backend_network:
type: str
default: nextcloud-internal
nextcloud_traefik_network:
type: str
default: proxy
nextcloud_use_ssl:
type: bool
default: true
nextcloud_enable_collabora:
type: bool
default: true
nextcloud_collabora_domain:
type: str
default: office.local.test
description: Hostname Nextcloud uses to talk to Collabora server-to-server.
nextcloud_collabora_public_domain:
type: str
description:
- Optional browser-facing hostname for Collabora; defaults to
C(nextcloud_collabora_domain) when unset. Set when split-horizon
uses different names for browser and server traffic.
nextcloud_collabora_disable_cert_verification:
type: bool
default: false
nextcloud_enable_drawio:
type: bool
default: false
description: Enable the integration_drawio Nextcloud app and configure the URL/theme.
nextcloud_drawio_url:
type: str
default: ''
description: Public draw.io URL used by the integration_drawio app.
nextcloud_drawio_theme:
type: str
choices: [kennedy, atlas, dark, sketch, min]
default: kennedy
nextcloud_drawio_offline:
type: str
choices: ['yes', 'no']
default: 'yes'
nextcloud_use_s3_storage:
type: bool
default: false
description: Use S3 primary object storage instead of the local data dir.
nextcloud_s3_key:
type: str
default: changeme
nextcloud_s3_secret:
type: str
default: changeme
nextcloud_s3_region:
type: str
default: us-east-1
nextcloud_s3_bucket:
type: str
default: nextcloud
nextcloud_s3_host:
type: str
default: s3.example.com
nextcloud_s3_port:
type: int
default: 443
nextcloud_s3_ssl:
type: bool
default: true
nextcloud_s3_usepath_style:
type: bool
default: true
nextcloud_s3_autocreate:
type: bool
default: false
nextcloud_admin_user:
type: str
default: admin
nextcloud_admin_password:
type: str
required: true
nextcloud_memory_limit_mb:
type: int
default: 1024
nextcloud_upload_limit_mb:
type: int
default: 2048
nextcloud_scale_factor:
type: int
default: 2
nextcloud_trusted_proxies:
type: str
default: '172.16.0.0/12'
description: Trusted proxy CIDR(s) — by default the Docker internal range.
nextcloud_enable_notify_push:
type: bool
default: false
nextcloud_notify_push_image:
type: str
default: icewind1991/notify_push:1.3.1
nextcloud_notify_push_domain:
type: str
description:
- Hostname used when calling C(occ notify_push:setup). Defaults to
the first C(nextcloud_domains) entry. Override with an internal
FQDN to avoid hairpinning the setup check through the DMZ; the
FQDN must also be in C(nextcloud_domains).
nextcloud_apps_to_install:
type: list
elements: str
default:
- groupfolders
- richdocuments
- spreed
- user_ldap
- user_oidc
- whiteboard
- files_lock
- notify_push
description:
- Non-default Nextcloud apps to install + enable.
Install/enable detection is idempotent — re-runs report C(ok)
when the app is already present and enabled.
nextcloud_oidc_allow_selfsigned:
type: bool
default: false
nextcloud_oidc_providers:
type: list
elements: dict
default: []
description: OIDC providers registered with the user_oidc app.
options:
identifier:
type: str
required: true
display_name:
type: str
required: true
client_id:
type: str
required: true
client_secret:
type: str
required: true
discovery_url:
type: str
required: true
scope:
type: str
default: openid email profile
unique_uid:
type: bool
default: true
check_bearer:
type: bool
default: false
send_id_token_hint:
type: bool
default: true
mapping:
type: dict
nextcloud_oidc_providers_removed:
type: list
elements: str
default: []
nextcloud_ldap_enabled:
type: bool
default: false
nextcloud_ldap_config:
type: dict
default: {}
description:
- Key/value pairs passed to C(occ ldap:set-config s01 KEY VALUE).
The role reads the current config first and only invokes
C(set-config) when a stored value differs.

View file

@ -0,0 +1,28 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: digitalboard
description: Deploy Nextcloud (fpm) + Redis + Postgres via Docker Compose behind Traefik
company: Digitalboard
license: MIT-0
min_ansible_version: "2.14"
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
galaxy_tags:
- nextcloud
- files
- collabora
- oidc
- docker
- traefik
- digitalboard
dependencies: []

View file

@ -1,22 +1,55 @@
#SPDX-License-Identifier: MIT-0
---
# tasks file for configuring Collabora in Nextcloud
- name: Configure Collabora WOPI URL
- name: Read current richdocuments config values
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:get richdocuments {{ item }}
loop:
- wopi_url
- public_wopi_url
- disable_certificate_verification
- wopi_allowlist
register: _richdocuments_current
changed_when: false
failed_when: false
- name: Build map of current richdocuments config
ansible.builtin.set_fact:
_richdocuments_cfg: "{{ _richdocuments_cfg | default({}) | combine({item.item: (item.stdout | default('')).strip()}) }}"
loop: "{{ _richdocuments_current.results }}"
loop_control:
label: "{{ item.item }}"
- name: Configure Collabora WOPI URL (server-to-server)
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set richdocuments wopi_url --value=https://{{ nextcloud_collabora_domain }}
when: _richdocuments_cfg.wopi_url != ('https://' ~ nextcloud_collabora_domain)
- name: Configure Collabora public WOPI URL (browser-facing)
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set richdocuments public_wopi_url --value=https://{{ nextcloud_collabora_public_domain }}
when:
- nextcloud_collabora_public_domain is defined
- nextcloud_collabora_public_domain != nextcloud_collabora_domain
- _richdocuments_cfg.public_wopi_url != ('https://' ~ nextcloud_collabora_public_domain)
- name: Configure certificate verification for Collabora
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set richdocuments disable_certificate_verification --value={{ nextcloud_collabora_disable_cert_verification | ternary('yes', 'no') }}
when: _richdocuments_cfg.disable_certificate_verification != (nextcloud_collabora_disable_cert_verification | ternary('yes', 'no'))
- name: Set Collabora WOPI allowlist
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set richdocuments wopi_allowlist --value=''
when: _richdocuments_cfg.wopi_allowlist | default('') != ''
- name: Activate richdocuments configuration (fetch discovery from Collabora)
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ richdocuments:activate-config
command: php /var/www/html/occ richdocuments:activate-config
changed_when: false

View file

@ -2,18 +2,41 @@
---
# tasks file for configuring draw.io in Nextcloud
- name: Read current drawio config values
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:get drawio {{ item }}
loop:
- DrawioUrl
- DrawioTheme
- DrawioOffline
register: _drawio_current
changed_when: false
failed_when: false
- name: Build map of current drawio config
ansible.builtin.set_fact:
_drawio_cfg: "{{ _drawio_cfg | default({}) | combine({item.item: (item.stdout | default('')).strip()}) }}"
loop: "{{ _drawio_current.results }}"
loop_control:
label: "{{ item.item }}"
- name: Configure draw.io URL
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set drawio DrawioUrl --value={{ nextcloud_drawio_url }}
when: nextcloud_drawio_url | length > 0
when:
- nextcloud_drawio_url | length > 0
- _drawio_cfg.DrawioUrl != nextcloud_drawio_url
- name: Configure draw.io theme
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set drawio DrawioTheme --value={{ nextcloud_drawio_theme }}
when: _drawio_cfg.DrawioTheme != (nextcloud_drawio_theme | string)
- name: Configure draw.io offline mode
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set drawio DrawioOffline --value={{ nextcloud_drawio_offline }}
command: php /var/www/html/occ config:app:set drawio DrawioOffline --value={{ nextcloud_drawio_offline }}
when: _drawio_cfg.DrawioOffline != (nextcloud_drawio_offline | string)

View file

@ -15,6 +15,24 @@
command: php /var/www/html/occ ldap:create-empty-config
when: "'s01' not in ldap_show_config.stdout"
- name: Read current LDAP config for s01
community.docker.docker_container_exec:
container: "{{ nextcloud_service_name }}-nextcloud-1"
command: php /var/www/html/occ ldap:show-config s01 --output=json
register: _ldap_show_s01
changed_when: false
failed_when: false
- name: Parse current LDAP config
ansible.builtin.set_fact:
_ldap_current: >-
{{
(_ldap_show_s01.stdout | from_json) if (
(_ldap_show_s01.stdout | default('') | trim) is match('^[\\[{]')
) else {}
}}
when: _ldap_show_s01.rc | default(1) == 0
- name: Configure LDAP settings
community.docker.docker_container_exec:
container: "{{ nextcloud_service_name }}-nextcloud-1"
@ -29,6 +47,7 @@
loop_control:
label: "{{ item.key }}"
no_log: true
when: ((_ldap_current | default({})).get(item.key) | default(none) | string) != (item.value | string)
- name: Test LDAP configuration
community.docker.docker_container_exec:

View file

@ -49,6 +49,61 @@
project_src: "{{ nextcloud_docker_compose_dir }}"
state: present
# nextcloud/server#59629: UserConfig::getValueBool() passes a non-string from
# getTypedValue() into strtolower() under PHP 8.x + OPcache, throwing a
# TypeError on every authenticated request once user_ldap is involved. Fix
# is in master (PR #59646) but no stable33 backport landed before 33.0.4.
# Apply the (string) cast in-container; idempotent via grep guard. Remove
# this block once nextcloud_image >= 33.0.4.
- name: Discover nextcloud php containers needing the UserConfig patch
ansible.builtin.shell:
cmd: >-
docker ps --filter "label=com.docker.compose.project={{ nextcloud_docker_compose_dir | basename }}"
--filter "label=com.docker.compose.service=nextcloud"
--format '{% raw %}{{.Names}}{% endraw %}'
register: _nextcloud_php_containers
changed_when: false
- name: Check UserConfig.php patch status per container
ansible.builtin.shell:
# rc 0 -> already patched; rc 1 -> still the unpatched original; rc 2 ->
# neither marker present (upstream drift -> the guard task below fails loud).
cmd: >-
docker exec {{ item }} sh -c '
grep -q "strtolower((string)\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 0;
grep -q "strtolower(\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 1;
exit 2'
loop: "{{ _nextcloud_php_containers.stdout_lines }}"
register: _nextcloud_userconfig_check
changed_when: false
failed_when: false
- name: Fail if the UserConfig.php source drifted from the expected upstream line
ansible.builtin.fail:
msg: >-
Neither the patched nor the expected original strtolower($this->getTypedValue(...))
line was found in {{ item.item }}:/var/www/html/lib/private/Config/UserConfig.php.
The nextcloud/server#59629 workaround can no longer locate its target — the upstream
source likely changed. Re-verify whether the fix shipped (then drop this block) or
update the sed expression. Silently skipping would let the TypeError regress.
loop: "{{ _nextcloud_userconfig_check.results }}"
loop_control:
label: "{{ item.item }}"
when:
- item.rc | default(2) == 2
- name: Apply UserConfig::getValueBool string-cast workaround
ansible.builtin.shell:
cmd: >-
docker exec {{ item.item }}
sed -i 's|$b = strtolower($this->getTypedValue|$b = strtolower((string)$this->getTypedValue|'
/var/www/html/lib/private/Config/UserConfig.php
loop: "{{ _nextcloud_userconfig_check.results }}"
loop_control:
label: "{{ item.item }}"
when:
- item.rc | default(2) == 1
- name: Wait for Nextcloud to be ready
ansible.builtin.shell:
cmd: docker compose exec -T nextcloud php /var/www/html/occ status --output=json

View file

@ -2,7 +2,16 @@
---
# tasks file for configuring notify_push in Nextcloud
- name: Read current notify_push base endpoint
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:get notify_push base_endpoint
register: _notify_push_current
changed_when: false
failed_when: false
- name: Configure notify_push base endpoint
community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_domain }}/push
command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_notify_push_domain | default(nextcloud_domains[0]) }}/push
when: (_notify_push_current.stdout | default('') | trim) != ('https://' ~ (nextcloud_notify_push_domain | default(nextcloud_domains[0])) ~ '/push')

View file

@ -8,7 +8,9 @@
chdir: "{{ nextcloud_docker_compose_dir }}"
loop: "{{ nextcloud_apps_to_install }}"
register: app_install_result
changed_when: "'installed' in app_install_result.stdout"
changed_when:
- "'already installed' not in app_install_result.stdout"
- "'installed' in app_install_result.stdout"
failed_when:
- app_install_result.rc != 0
- "'already installed' not in app_install_result.stdout"
@ -19,7 +21,9 @@
chdir: "{{ nextcloud_docker_compose_dir }}"
loop: "{{ nextcloud_apps_to_install }}"
register: app_enable_result
changed_when: "'enabled' in app_enable_result.stdout"
changed_when:
- "'already enabled' not in app_enable_result.stdout"
- "'enabled' in app_enable_result.stdout"
failed_when:
- app_enable_result.rc != 0
- "'already enabled' not in app_enable_result.stdout"

View file

@ -35,10 +35,13 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network={{ nextcloud_traefik_network }}
- traefik.http.routers.{{ nextcloud_service_name }}.rule=Host(`{{ nextcloud_domain }}`)
- traefik.http.routers.{{ nextcloud_service_name }}.rule={% for d in nextcloud_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
{% if nextcloud_use_ssl %}
- traefik.http.routers.{{ nextcloud_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ nextcloud_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ nextcloud_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ nextcloud_service_name }}.entrypoints=web
{% endif %}
@ -60,7 +63,7 @@ services:
PHP_MEMORY_LIMIT: {{ nextcloud_memory_limit_mb }}M
PHP_UPLOAD_LIMIT: {{ nextcloud_upload_limit_mb }}M
OVERWRITEPROTOCOL: https
OVERWRITEHOST: {{ nextcloud_domain }}
OVERWRITEHOST: {{ nextcloud_domains[0] }}
TRUSTED_PROXIES: "{{ nextcloud_trusted_proxies }}"
volumes:
- {{ nextcloud_docker_volume_dir }}/nextcloud/:/var/www/html
@ -69,6 +72,12 @@ services:
{% for net in nextcloud_extra_networks %}
- {{ net }}
{% endfor %}
{% if nextcloud_extra_hosts is defined and nextcloud_extra_hosts | length > 0 %}
extra_hosts:
{% for host in nextcloud_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
nextcloud:
image: {{ nextcloud_image }}
@ -88,7 +97,7 @@ services:
PHP_MEMORY_LIMIT: {{ nextcloud_memory_limit_mb }}M
PHP_UPLOAD_LIMIT: {{ nextcloud_upload_limit_mb }}M
OVERWRITEPROTOCOL: https
OVERWRITEHOST: {{ nextcloud_domain }}
OVERWRITEHOST: {{ nextcloud_domains[0] }}
TRUSTED_PROXIES: "{{ nextcloud_trusted_proxies }}"
{% if nextcloud_use_s3_storage %}
OBJECTSTORE_S3_KEY: {{ nextcloud_s3_key }}
@ -127,7 +136,7 @@ services:
environment:
PORT: "7867"
REDIS_URL: "redis://redis:6379"
DATABASE_URL: "postgres://{{ nextcloud_postgres_user }}:{{ nextcloud_postgres_password }}@db:5432/{{ nextcloud_postgres_db }}"
DATABASE_URL: "postgres://{{ nextcloud_postgres_user | urlencode | replace('/', '%2F') }}:{{ nextcloud_postgres_password | urlencode | replace('/', '%2F') }}@db:5432/{{ nextcloud_postgres_db }}"
DATABASE_PREFIX: "oc_"
NEXTCLOUD_URL: "http://nginx"
networks:
@ -136,11 +145,14 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network={{ nextcloud_traefik_network }}
- traefik.http.routers.{{ nextcloud_service_name }}-push.rule=Host(`{{ nextcloud_domain }}`) && PathPrefix(`/push`)
- traefik.http.routers.{{ nextcloud_service_name }}-push.rule=({% for d in nextcloud_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor %}) && PathPrefix(`/push`)
- traefik.http.services.{{ nextcloud_service_name }}-push.loadbalancer.server.port=7867
{% if nextcloud_use_ssl %}
- traefik.http.routers.{{ nextcloud_service_name }}-push.entrypoints=websecure
- traefik.http.routers.{{ nextcloud_service_name }}-push.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ nextcloud_service_name }}-push.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ nextcloud_service_name }}-push.entrypoints=web
{% endif %}

View file

@ -1,38 +1,43 @@
Role Name
=========
# opencloud
A brief description of the role goes here.
Deploys [OpenCloud](https://opencloud.eu/) (`opencloudeu/opencloud`) as a
self-contained file platform via Docker Compose behind Traefik. Supports
the built-in IdP or external OIDC, optional S3 storage, external LDAP,
Collabora and draw.io integration, and OIDC-claim-based role assignment.
Requirements
------------
## Role variables
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
A selection of the most relevant variables — see
[defaults/main.yml](defaults/main.yml) for the full set.
Role Variables
--------------
| Variable | Default | Description |
| --- | --- | --- |
| `opencloud_domain` | `opencloud.local.test` | FQDN the Traefik router matches. |
| `opencloud_image` | `opencloudeu/opencloud:latest` | Container image. |
| `opencloud_port` | `9200` | Container port Traefik forwards to. |
| `opencloud_admin_password` | `admin` | Initial admin password — **override this**. |
| `opencloud_traefik_network` | `proxy` | Docker network shared with Traefik. |
| `opencloud_use_ssl` | `true` | Enable the TLS resolver on the router. |
| `opencloud_oidc_issuer` | `""` | External OIDC issuer; empty uses the built-in IdP. |
| `opencloud_use_s3_storage` | `false` | Use S3 storage instead of local disk. |
| `opencloud_ldap_uri` | `""` | External LDAP URI; empty uses the built-in directory. |
| `opencloud_collabora_domain` | `""` | Collabora server domain; set with `opencloud_wopi_domain` to enable editing. |
| `opencloud_wopi_domain` | `""` | WOPI server FQDN; required alongside `opencloud_collabora_domain`. |
| `opencloud_drawio_url` | `""` | draw.io URL; set to enable diagram editing. |
| `opencloud_role_assignment_driver` | `default` | Set to `oidc` to map OIDC claims to roles. |
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
## Example
Dependencies
------------
```yaml
- hosts: services
become: true
roles:
- role: digitalboard.core.opencloud
vars:
opencloud_domain: "opencloud.example.com"
opencloud_admin_password: "{{ vault_opencloud_admin_password }}"
```
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
## License
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
MIT-0

View file

@ -5,4 +5,4 @@
- name: restart opencloud
community.docker.docker_compose_v2:
project_src: "{{ opencloud_docker_compose_dir }}"
state: restarted
state: present

View file

@ -1,35 +1,27 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy OpenCloud file platform via Docker Compose behind Traefik
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
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.
galaxy_tags:
- opencloud
- files
- storage
- docker
- traefik
- digitalboard
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -11,9 +11,10 @@ Docker Compose stack behind Traefik.
- 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)
## What this role does NOT do
- Does not pre-configure OIDC / identity_connections — set up via Admin UI
- Does not migrate existing OpnForm databases — only bootstraps fresh
installs (admin registration + OIDC connection are idempotent)
## Architecture note: why two reverse proxies?
@ -45,10 +46,14 @@ 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_jwt_secret` | 32-byte hex string | `openssl rand -hex 32` |
| `opnform_front_api_secret` | 32-byte hex string | `openssl rand -hex 32` |
| `opnform_db_password` | strong password | `openssl rand -base64 24` |
`opnform_app_key` MUST keep the `base64:` prefix — the validation task
asserts it. `opnform_jwt_secret` and `opnform_front_api_secret` have no
enforced format; any sufficiently random value works.
When `opnform_oidc_enabled` is `true`:
| Variable | Source |
@ -91,11 +96,14 @@ Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit
## OIDC setup
Set `opnform_oidc_enabled: true` and the role creates an
Set `opnform_oidc_enabled: true` and the role provisions 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).
single OIDC connection per workspace, so the task is idempotent: it GETs
existing connections first, then either POSTs a new one or PATCHes the
existing one to the desired state. PATCHing (rather than skipping when
one exists) keeps inventory changes — e.g. a corrected issuer — applied
on re-runs instead of leaving stale values in the DB.
**Prerequisite**: the admin bootstrap must be configured
(`opnform_admin_email` + `opnform_admin_password`). The OIDC API
@ -138,6 +146,50 @@ opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin
Valid roles: `owner`, `admin`, `editor`, `member`.
### Force OIDC-only login
```yaml
opnform_oidc_force_login: true # default false
```
Sets `OIDC_FORCE_LOGIN=true` on the API: password login is disabled and
every user must authenticate via OIDC. The role keeps force-login **off**
during the first deploy (the admin/OIDC bootstrap is password-based) and
switches it on only after the OIDC connection is provisioned, recreating
the API containers. Ensure all real users have addresses under
`opnform_oidc_domain` before enabling — there is no password fallback.
### Direct-SSO entrypoint
OpnForm has no native way to skip the email login form and jump straight
to the IdP. When enabled, the ingress serves a tiny redirect page that
calls `/api/auth/{slug}/redirect` (no domain check) and forwards the
browser to the IdP authorize URL.
```yaml
opnform_oidc_sso_entrypoint: true # default false
opnform_oidc_sso_path: "/sso" # link users to https://<domain>/sso
opnform_oidc_sso_redirect_root: true # default false — root URL 302s to <sso_path>
```
With `opnform_oidc_sso_redirect_root` enabled both the bare hostname
and `/login` jump straight to the IdP. Public form deep-links
(`/forms/<slug>`, `/admin/...`) are not touched. The email form remains
reachable as a break-glass path via `/login?bypass=1`.
## Networking / split-horizon
```yaml
opnform_extra_domains: [] # extra Host-rule hostnames (OR-combined)
opnform_extra_hosts: [] # API container /etc/hosts overrides ("host:ip")
```
`opnform_extra_domains` adds internal `*.int.*` FQDNs so a DMZ
reverseproxy can reach a backend hostname covered by the cert.
`opnform_extra_hosts` lets the API containers reach the IdP's public FQDN
(used in the OIDC `iss` claim) over the LAN when the DMZ has no NAT
loopback.
## Example playbook
```yaml
@ -167,3 +219,7 @@ opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/opnform',
mount_point='kv').data.data.db_password }}"
```
## License
MIT-0

View file

@ -16,6 +16,15 @@ opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis"
# Service configuration
opnform_domain: "forms.local.test"
# Additional hostnames the opnform router answers on (e.g. an internal
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
opnform_extra_domains: []
# Container-level /etc/hosts overrides for the API containers — needed in
# split-horizon setups where the OpnForm API must reach the IdP's public
# FQDN (used in the OIDC discovery/iss claim) over the LAN rather than
# hairpinning through a DMZ that has no NAT loopback to its own public IP.
opnform_extra_hosts: []
opnform_base_url: "https://forms.local.test"
# Images
@ -92,6 +101,12 @@ opnform_oidc_slug: "oidc"
# with @example.com emails are redirected to the IdP). Required when
# opnform_oidc_enabled is true.
opnform_oidc_domain: ""
# When true, sets OIDC_FORCE_LOGIN on the api: password-based login is
# disabled entirely and every user must authenticate via OIDC. Only
# rendered when opnform_oidc_enabled is also true. Make sure all real
# users have addresses under opnform_oidc_domain before enabling — there
# is no password fallback once this is on.
opnform_oidc_force_login: false
opnform_oidc_scopes:
- openid
- profile
@ -104,6 +119,24 @@ opnform_oidc_admin_group: "opnform-admins"
# var. Each item: {idp_group: "<group name>", role: "owner|admin|editor|member"}
opnform_oidc_group_role_mappings: []
# Direct-SSO entrypoint. OpnForm has no built-in way to skip the email
# login form and jump straight to the IdP (verified: config/oidc.php only
# exposes force_login; the login form always routes by email domain). When
# this is enabled the ingress serves a tiny page at opnform_oidc_sso_path
# that calls OpnForm's /api/auth/{slug}/redirect endpoint (which performs
# no domain check) and forwards the browser to the returned authorize URL
# — nonce/state included. Link users to https://<domain><sso_path> instead
# of /login. Requires opnform_oidc_enabled.
opnform_oidc_sso_entrypoint: false
opnform_oidc_sso_path: "/sso"
# When true, the ingress 302-redirects the root URL (exact-match on `/`)
# to opnform_oidc_sso_path so visiting https://<domain>/ jumps straight
# to the IdP login without showing OpnForm's email form. Public form
# deep-links (`/forms/<slug>`, `/login`, etc.) are untouched.
# Requires opnform_oidc_sso_entrypoint=true.
opnform_oidc_sso_redirect_root: false
# Traefik configuration
opnform_traefik_network: "proxy"
opnform_use_ssl: true

View file

@ -6,3 +6,13 @@
community.docker.docker_compose_v2:
project_src: "{{ opnform_docker_compose_dir }}"
state: restarted
# nginx.conf is bind-mounted into the ingress container and rendered to
# /etc/nginx/conf.d/default.conf by the envsubst entrypoint on container
# start. Plain `docker restart` re-runs that entrypoint, so the new
# template is picked up without bouncing db/redis/api/ui.
- name: restart opnform ingress
community.docker.docker_container:
name: opnform-ingress
state: started
restart: true

View file

@ -38,6 +38,25 @@ argument_specs:
type: str
default: forms.local.test
description: Hostname used in the traefik Host rule.
opnform_extra_domains:
type: list
elements: str
default: []
description:
- Additional hostnames the Traefik router answers on, OR-combined
with C(opnform_domain). Useful for an internal C(*.int.*) FQDN so
a DMZ reverseproxy can reach a backend hostname covered by the
cert.
opnform_extra_hosts:
type: list
elements: str
default: []
description:
- Container-level C(/etc/hosts) overrides for the API containers
(Compose C(extra_hosts) entries, C("host:ip")). Needed in
split-horizon setups where the OpnForm API must reach the IdP's
public FQDN (used in the OIDC discovery / C(iss) claim) over the
LAN rather than hairpinning through a DMZ with no NAT loopback.
opnform_base_url:
type: str
default: https://forms.local.test
@ -184,6 +203,15 @@ argument_specs:
description:
- Email domain that triggers OIDC for matching users. Required
when C(opnform_oidc_enabled=true).
opnform_oidc_force_login:
type: bool
default: false
description:
- "When true, sets C(OIDC_FORCE_LOGIN=true) on the api container:
password-based login is disabled and every user must authenticate
via OIDC. Only takes effect when C(opnform_oidc_enabled=true).
Ensure all real users have addresses under C(opnform_oidc_domain)
before enabling — there is no password fallback."
opnform_oidc_scopes:
type: list
elements: str
@ -211,6 +239,33 @@ argument_specs:
type: str
required: true
choices: [owner, admin, editor, member]
opnform_oidc_sso_entrypoint:
type: bool
default: false
description:
- When true (and C(opnform_oidc_enabled=true)) the nginx ingress
serves a small redirect page at C(opnform_oidc_sso_path) that
calls OpnForm's C(/api/auth/{slug}/redirect) endpoint and
forwards the browser to the returned IdP authorize URL. Lets
you link users straight to the IdP, skipping OpnForm's
email-based login form. OpnForm has no native option for this.
opnform_oidc_sso_path:
type: str
default: /sso
description:
- Path (on C(opnform_domain)) where the direct-SSO redirect page
is served when C(opnform_oidc_sso_entrypoint=true). Must start
with C(/) and not collide with OpnForm's own routes.
opnform_oidc_sso_redirect_root:
type: bool
default: false
description:
- When true, the nginx ingress 302-redirects the root URL
(exact-match on C(/)) to C(opnform_oidc_sso_path), so visiting
C(https://<domain>/) jumps straight to the IdP without
OpnForm's email login form. Public form deep-links
(C(/forms/<slug>), C(/login), C(/admin/...)) are untouched.
Requires C(opnform_oidc_sso_entrypoint=true).
opnform_traefik_network:
type: str

View file

@ -15,10 +15,11 @@
- opnform_front_api_secret | length > 0
- opnform_db_password | length > 0
fail_msg: >-
OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret,
OpnForm requires opnform_app_key, 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:"
Generate with:
opnform_app_key='base64:'$(openssl rand -base64 32) (the 'base64:' prefix is required);
opnform_jwt_secret and opnform_front_api_secret via openssl rand -hex 32.
Provide via OpenBao, Ansible Vault or extra-vars.
success_msg: Secrets validation passed
@ -74,13 +75,97 @@
src: nginx.conf.j2
dest: "{{ opnform_docker_compose_dir }}/nginx.conf"
mode: '0644'
notify: restart opnform
notify: restart opnform ingress
# OIDC_FORCE_LOGIN disables OpnForm's password login — including the
# password-based admin/OIDC bootstrap this role performs below. The
# bootstrap must therefore run with force-login OFF. To stay idempotent
# on re-runs (avoid recreating api containers on every apply), we only
# turn force-login OFF when the bootstrap is actually needed (first run
# on a fresh host, no OIDC connection yet). Once the connection exists
# we render the final force-login value straight away, so the compose
# file is byte-identical across re-runs.
- name: Probe whether OpnForm is already bootstrapped
block:
- name: Check if opnform-api container exists and is healthy
ansible.builtin.command:
cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api
register: _opnform_api_health_probe
changed_when: false
failed_when: false
- name: Attempt admin login (only when api is healthy)
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_probe_login
no_log: true
when:
- _opnform_api_health_probe.rc == 0
- _opnform_api_health_probe.stdout == "healthy"
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- name: Probe for existing OIDC connection
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces"
method: GET
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ _opnform_probe_login.json.token }}"
status_code: 200
validate_certs: false
register: _opnform_probe_workspaces
no_log: true
when:
- opnform_oidc_enabled | bool
- _opnform_probe_login is defined
- _opnform_probe_login.status | default(0) == 200
- name: Probe OIDC connections on default workspace
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces/{{ _opnform_probe_workspaces.json[0].id }}/oidc-connections"
method: GET
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ _opnform_probe_login.json.token }}"
status_code: 200
validate_certs: false
register: _opnform_probe_oidc
no_log: true
when:
- opnform_oidc_enabled | bool
- _opnform_probe_workspaces is defined
- _opnform_probe_workspaces.json | default([]) | length > 0
- name: Decide whether force-login can render in its final state
ansible.builtin.set_fact:
# True when force-login is desired AND admin+OIDC bootstrap has
# already completed (admin user exists with the configured password,
# OIDC connection is present). On a fresh host both checks fail and
# we fall back to false so the bootstrap below can run.
_opnform_force_login_effective: >-
{{
(opnform_oidc_enabled | bool)
and (opnform_oidc_force_login | bool)
and (_opnform_probe_login.status | default(0) == 200)
and ((_opnform_probe_oidc.json | default([])) | length > 0)
}}
- name: Deploy docker-compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
register: _opnform_compose_rendered
notify: restart opnform
# =====================================================================
@ -113,6 +198,12 @@
# 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.
#
# Skipped entirely when force-login already rendered in its final state
# (probe in step 2 confirmed admin + connection exist). Re-running the
# /api/login probe on a force-login-enabled api would 401 and 422, so
# avoid the noise — and avoid spurious "changed" status from a register
# call that won't help anyway.
- name: Check if OpnForm admin user already exists
ansible.builtin.uri:
@ -130,6 +221,7 @@
when:
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- not (_opnform_force_login_effective | bool)
- name: Create OpnForm admin user via /api/register
ansible.builtin.uri:
@ -150,14 +242,25 @@
when:
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- opnform_admin_login.status != 200
- not (_opnform_force_login_effective | bool)
- opnform_admin_login.status | default(0) != 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.
# Provisions a single OIDC connection on the admin's default workspace.
# OpnForm enforces one OIDC connection per workspace, so we GET the
# existing connections first and then either POST a new one or PATCH the
# existing one to the desired state. PATCHing (rather than skipping when
# one exists) keeps inventory changes — e.g. a corrected issuer — applied
# on re-runs instead of leaving stale values in the DB forever.
#
# Skipped on re-applies when force-login is already enabled — the API
# password login required for these calls is disabled, and the connection
# is known to exist (otherwise force-login wouldn't have rendered in its
# final state in step 2). To intentionally re-provision the connection
# from inventory changes on such a host: temporarily set
# opnform_oidc_force_login=false, re-apply, then set it back to true.
- name: Log in as admin to obtain OIDC API token
ansible.builtin.uri:
@ -173,7 +276,9 @@
validate_certs: false
register: opnform_oidc_token
no_log: true
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- name: Fetch admin's workspaces
ansible.builtin.uri:
@ -186,7 +291,9 @@
validate_certs: false
register: opnform_workspaces
no_log: true
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- name: Fetch existing OIDC connections for the default workspace
ansible.builtin.uri:
@ -199,7 +306,9 @@
validate_certs: false
register: opnform_existing_oidc
no_log: true
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- name: Resolve OIDC group-role mappings
ansible.builtin.set_fact:
@ -211,17 +320,16 @@
([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}]
if (opnform_oidc_admin_group | length > 0) else [])
}}
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | 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:
# Desired connection state shared by both the create (POST) and update
# (PATCH) calls below. client_secret is always sent: OpnForm's update
# endpoint only persists it when present, and on create it is required.
- name: Build desired OIDC connection body
ansible.builtin.set_fact:
_opnform_oidc_body:
name: "{{ opnform_oidc_client_name }}"
slug: "{{ opnform_oidc_slug }}"
domain: "{{ opnform_oidc_domain }}"
@ -233,13 +341,93 @@
options:
require_state: true
group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}"
no_log: true
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | 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: "{{ _opnform_oidc_body }}"
status_code: [201]
validate_certs: false
no_log: true
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- opnform_existing_oidc.json | length == 0
# An OIDC connection already exists: PATCH it to the desired state so
# inventory changes (e.g. a corrected issuer) are applied. OpnForm allows
# exactly one connection per workspace, so the first entry is ours.
- name: Update existing OIDC identity connection
ansible.builtin.uri:
url: >-
https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections/{{ opnform_existing_oidc.json[0].id }}
method: PATCH
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
body_format: json
body: "{{ _opnform_oidc_body }}"
status_code: [200]
validate_certs: false
no_log: true
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- opnform_existing_oidc.json | length > 0
# =====================================================================
# 7. ENABLE FORCE LOGIN (first-run only)
# =====================================================================
# On the very first apply, step 2 rendered the compose file with
# force-login disabled (so the bootstrap above could use the password
# login). Now that the OIDC connection exists, re-render the compose
# file with force-login in its final state and recreate the api
# containers once.
#
# On all subsequent applies the probe in step 2 already rendered the
# final value, the compose file is byte-identical here, and this block
# is a no-op (the template task reports "ok", no recreate).
- name: Enable force login (first run, after OIDC bootstrap)
when:
- opnform_oidc_enabled | bool
- opnform_oidc_force_login | bool
- not (_opnform_force_login_effective | bool)
block:
- name: Re-render compose with force-login enabled
ansible.builtin.set_fact:
_opnform_force_login_effective: true
- name: Deploy docker-compose file with force-login enabled
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
register: _opnform_force_login_compose
- name: Apply force-login by recreating the api containers
community.docker.docker_compose_v2:
project_src: "{{ opnform_docker_compose_dir }}"
state: present
wait: true
wait_timeout: 180
when: _opnform_force_login_compose is changed
- name: Restart ingress so nginx picks up the new api container IPs
community.docker.docker_container:
name: opnform-ingress
state: started
restart: true
when: _opnform_force_login_compose is changed
- name: Display deployment info
ansible.builtin.debug:
msg: |-
@ -260,6 +448,11 @@
(slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }})
Users with @{{ opnform_oidc_domain }} addresses will be
redirected to {{ opnform_oidc_issuer }} on login.
{% if opnform_oidc_sso_entrypoint %}
Login intercept active: {{ opnform_base_url }}/login forwards
directly to the IdP. Use {{ opnform_base_url }}/login?bypass=1
as a break-glass path for the email form when the IdP is down.
{% endif %}
{% else %}
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)
{% endif %}

View file

@ -6,6 +6,12 @@ services:
image: {{ opnform_api_image }}
container_name: opnform-api
restart: unless-stopped
{% if opnform_extra_hosts | length > 0 %}
extra_hosts:
{% for host in opnform_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
volumes:
- {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw
environment: &api-env
@ -14,6 +20,9 @@ services:
APP_URL: "{{ opnform_base_url }}"
APP_DEBUG: "false"
SELF_HOSTED: "true"
{% if opnform_oidc_enabled and (_opnform_force_login_effective | default(false)) %}
OIDC_FORCE_LOGIN: "true"
{% endif %}
LOG_CHANNEL: errorlog
LOG_LEVEL: info
@ -51,6 +60,14 @@ services:
JWT_TTL: "1440"
JWT_SECRET: "{{ opnform_jwt_secret }}"
# Shared secret for trusted SSR requests from the Nuxt UI. The UI
# forwards JWTs server-side with its own user agent; without this
# secret the API's AuthenticateJWT middleware would reject those
# requests (UA mismatch -> token blacklisted -> the next genuine
# browser request 401s). Must match FRONT_API_SECRET on the ui
# service.
FRONT_API_SECRET: "{{ opnform_front_api_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 }}"
@ -105,7 +122,13 @@ services:
NUXT_PUBLIC_API_BASE: "/api"
NUXT_PRIVATE_API_BASE: "http://ingress/api"
NUXT_PUBLIC_ENV: production
FRONT_API_SECRET: "{{ opnform_front_api_secret }}"
# Nuxt runtimeConfig.apiSecret is fed by NUXT_API_SECRET (Nuxt
# convention: NUXT_<key> populates runtimeConfig.<key>). The UI
# injects this as `x-api-secret` on SSR-side forwards to Laravel,
# which then short-circuits the UA-fingerprint check in
# AuthenticateJWT — without it every reload would invalidate the
# JWT (UA `node` vs UA at issue time) and 401.
NUXT_API_SECRET: "{{ opnform_front_api_secret }}"
depends_on:
api:
condition: service_healthy
@ -173,10 +196,13 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network={{ opnform_traefik_network }}
- traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`)
- traefik.http.routers.{{ opnform_service_name }}.rule={% set _all_domains = [opnform_domain] + (opnform_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
{% if opnform_use_ssl %}
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ opnform_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ opnform_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=web
{% endif %}

View file

@ -15,9 +15,53 @@ server {
index index.html index.htm index.php;
# Re-resolve upstream container hostnames via Docker's embedded DNS
# at request time. Without this, nginx caches the first resolution
# forever; if `api` or `ui` get recreated and pick up a new IP, every
# request 502s until the ingress itself is restarted.
resolver 127.0.0.11 valid=10s ipv6=off;
set $upstream_api api;
set $upstream_ui ui;
{% if opnform_oidc_enabled and opnform_oidc_sso_entrypoint %}
# Root → /login. Public forms live under /forms/<slug>, so the bare
# hostname only serves the authenticated dashboard — sending it
# straight to /login (which then jumps to the IdP) saves an extra
# UI-side redirect for anyone who lands there.
location = / {
return 302 /login;
}
# /login intercept: serve a tiny HTML page that calls OpnForm's
# /api/auth/{slug}/redirect endpoint and forwards the browser to the
# IdP authorize URL — skipping the email-based login form entirely.
# Break-glass: /login?bypass=1 falls through to the UI's own login
# form so the email/password path stays reachable when the IdP is
# down. Bypass branches to a named location (`@login_bypass`) because
# `proxy_pass` inside an `if` block is invalid nginx config.
location = /login {
if ($arg_bypass = "1") {
error_page 418 = @login_bypass;
return 418;
}
default_type text/html;
return 200 '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Redirecting to sign-in…</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;color:#374151"><p id="m">Redirecting to sign-in…</p><script>fetch("/api/auth/{{ opnform_oidc_slug }}/redirect",{method:"POST",headers:{Accept:"application/json"}}).then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json()}).then(function(d){if(d&&d.redirect_url){window.location.replace(d.redirect_url)}else{throw new Error("no redirect_url")}}).catch(function(e){document.getElementById("m").textContent="Sign-in redirect failed: "+e.message+". Open /login?bypass=1 to use the email form.";});</script></body></html>';
}
location @login_bypass {
proxy_http_version 1.1;
proxy_pass http://$upstream_ui:3000/login;
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;
}
{% endif %}
location / {
proxy_http_version 1.1;
proxy_pass http://ui:3000;
proxy_pass http://$upstream_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;
@ -34,7 +78,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass api:9000;
fastcgi_pass $upstream_api:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php;

View file

@ -48,13 +48,17 @@ With S3 (Garage) backend:
```yaml
send_storage_backend: s3
send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}"
send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domains'][0] }}"
send_s3_bucket: "send"
send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}"
send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}"
```
When `send_storage_backend: s3`, the role asserts that `send_s3_endpoint`,
`send_s3_bucket`, `send_s3_access_key` and `send_s3_secret_key` are all set,
and fails early otherwise.
License
-------
MIT
MIT-0

View file

@ -2,7 +2,7 @@
galaxy_info:
author: digitalboard
description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose
license: MIT
license: MIT-0
min_ansible_version: "2.14"

View file

@ -50,6 +50,9 @@ services:
{% if send_use_ssl %}
- traefik.http.routers.{{ send_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ send_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ send_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ send_service_name }}.entrypoints=web
{% endif %}

View file

@ -1,38 +1,103 @@
Role Name
=========
# Traefik
A brief description of the role goes here.
Ansible role to deploy Traefik v3 as a reverse proxy via Docker Compose,
either as a public-facing DMZ proxy (file provider) or as a backend
application proxy (docker provider).
Requirements
------------
## Requirements
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
- Docker and Docker Compose installed on the target host
- Ansible collection: `community.docker`
- For ACME DNS-01: an RFC2136-capable nameserver with a delegated zone
for `_acme-challenge` records and a TSIG key
Role Variables
--------------
## Role variables
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
Full list with types and defaults: `meta/argument_specs.yml`. The most
common overrides:
Dependencies
------------
### Deployment mode
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
- `traefik_mode`: `dmz` (file provider, routes to external backends) or
`backend` (docker provider, discovers local containers). Default `backend`.
- `traefik_backend_servers_to_proxy`: in `dmz` mode, restrict which
inventory hosts the DMZ aggregates services from. Empty = all members
of `backend_servers`.
Example Playbook
----------------
### Networking
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- `traefik_network`: docker network connecting traefik to its containers
(default `proxy`).
- `traefik_extra_hosts`: list of `host:ip` entries injected as the
container's `extra_hosts`. Use when a downstream middleware
(e.g. ForwardAuth to authentik on a sibling LAN) must resolve a public
FQDN to an internal IP because the DMZ does not hairpin the public
address back inside.
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
### Certificates
License
-------
- `traefik_cert_mode`: `acme` (Let's Encrypt via DNS-01) or `selfsigned`
(local wildcard). Default `selfsigned`.
- `traefik_acme_dns_zone`, `traefik_acme_dns_nameserver`,
`traefik_acme_tsig_key`, `traefik_acme_tsig_secret`: RFC2136 / TSIG
configuration for the ACME DNS-01 challenge.
- `traefik_acme_tcp_only`: force lego's DNS lookups onto TCP/53 when the
container cannot reach the nameserver over UDP.
- `traefik_acme_disable_ans_checks`: skip the authoritative-NS
propagation check when the SOA-listed NS resolves to an unreachable IP.
BSD
### Dashboard
Author Information
------------------
- `traefik_enable_dashboard`: expose the traefik dashboard.
- `traefik_dashboard_domain`: when set, publish the dashboard on this
Host rule instead of the insecure port.
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
## Dependencies
- Run `digitalboard.core.base` first (or otherwise install Docker and the
`community.docker` collection); this role manages containers and networks
through `community.docker`.
- The Traefik network (`traefik_network`, default `proxy`) is created by
this role (`community.docker.docker_network`, state present), so no
pre-creation is required.
- In `dmz` mode, backend hosts advertise the services to aggregate via the
`traefik_dmz_exposed_services` host_var; `traefik_services` defines extra
routes directly on the DMZ host (each entry must set `backend_host`).
## Example playbook
Backend mode (one app server per host, docker provider):
```yaml
- hosts: app_servers
roles:
- role: digitalboard.core.traefik
vars:
traefik_mode: backend
traefik_cert_mode: acme
traefik_ssl_email: ops@example.com
traefik_acme_dns_zone: "_acme.example.com."
traefik_acme_dns_nameserver: "10.0.0.53:53"
traefik_acme_tsig_key: "acme-key"
traefik_acme_tsig_secret: "{{ vault_traefik_tsig_secret }}"
```
DMZ mode (aggregates services from `backend_servers`):
```yaml
- hosts: dmz_servers
roles:
- role: digitalboard.core.traefik
vars:
traefik_mode: dmz
traefik_cert_mode: acme
traefik_backend_servers_to_proxy:
- app01
- app02
traefik_extra_hosts:
- "auth.example.com:172.16.19.101"
```
## License
MIT-0

View file

@ -11,6 +11,13 @@ service_name: traefik
docker_compose_dir: "{{ docker_compose_base_dir }}/{{ service_name }}"
docker_volume_dir: "{{ docker_volume_base_dir }}/{{ service_name }}"
# Optional /etc/hosts entries injected into the traefik container. Useful
# when downstream middlewares (e.g. ForwardAuth to an authentik instance
# running on a sibling LAN) need a public FQDN to resolve to an internal
# IP because the DMZ doesn't hairpin the public address back inside.
# Example: ["auth.example.com:172.16.19.101"]
traefik_extra_hosts: []
# Deployment mode: 'dmz' or 'backend'
# - dmz: Public-facing reverse proxy that routes to backend servers using file provider
# - backend: Application server with docker provider for local container discovery
@ -33,6 +40,18 @@ traefik_acme_tsig_secret: "" # TSIG secret
traefik_acme_propagation_timeout: "120"
traefik_acme_polling_interval: "2"
traefik_acme_ttl: "60"
# Force lego's DNS lookups (SOA resolution, propagation checks) onto
# TCP instead of UDP. Useful when container egress can reach the
# nameserver on TCP/53 but UDP/53 is blocked or unreliable. Sets the
# upstream env var LEGO_EXPERIMENTAL_DNS_TCP_ONLY=true on the
# traefik container.
traefik_acme_tcp_only: false
# Disable lego's propagation check against the zone's authoritative
# nameservers. Use when the SOA-listed NS hostname resolves to an
# address that isn't reachable from this traefik host (e.g. a DMZ
# box that can only see the internal NS IP, not the public one).
# lego still polls via the configured `resolvers:` list.
traefik_acme_disable_ans_checks: false
# Self-signed certificate configuration (for vagrant/testing)
traefik_selfsigned_cert_dir: "{{ docker_volume_dir }}/certs"

View file

@ -5,4 +5,4 @@
- name: restart traefik
community.docker.docker_compose_v2:
project_src: "{{ docker_compose_dir }}"
state: restarted
state: present

View file

@ -0,0 +1,216 @@
---
argument_specs:
main:
short_description: Deploy Traefik v3 as DMZ or backend reverse proxy via Docker Compose.
description:
- Renders a Docker Compose stack for Traefik with either the file provider
(DMZ mode, routes to external backends) or the docker provider (backend
mode, discovers local containers via labels).
- Supports ACME DNS-01 issuance (RFC2136 / TSIG) or a self-signed cert
bundle for local/Vagrant setups.
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
description: Base directory under which the per-service compose dir is created.
docker_volume_base_dir:
type: path
default: /srv/data
description: Base directory under which the per-service volume dir is created.
service_name:
type: str
default: traefik
description: Compose project / service name; also used to build the per-service paths.
docker_compose_dir:
type: path
description: Compose project directory; defaults to C({{ docker_compose_base_dir }}/{{ service_name }}).
docker_volume_dir:
type: path
description: Per-service volume directory; defaults to C({{ docker_volume_base_dir }}/{{ service_name }}).
traefik_extra_hosts:
type: list
elements: str
default: []
description:
- Entries injected as C(extra_hosts) on the traefik container.
- Each entry has the Docker syntax C("host:ip").
- Useful when a downstream middleware (e.g. ForwardAuth to authentik
on a sibling LAN) must resolve a public FQDN to an internal IP
because the DMZ does not hairpin the public address.
traefik_mode:
type: str
choices: [dmz, backend]
default: backend
description:
- C(dmz) configures the file provider so the proxy forwards to
backend hosts (typically aggregated from the C(backend_servers) group).
- C(backend) configures the docker provider for local container discovery.
traefik_use_ssl:
type: bool
default: true
description: Toggle TLS on the websecure entrypoint.
traefik_ssl_email:
type: str
default: admin@example.com
description: Contact e-mail used by the ACME resolver.
traefik_ssl_cert_resolver:
type: str
default: dns
description: Certificate resolver name referenced in router labels.
traefik_cert_mode:
type: str
choices: [acme, selfsigned]
default: selfsigned
description: C(acme) for Let's Encrypt via DNS-01, C(selfsigned) for a locally generated bundle.
traefik_acme_dns_zone:
type: str
default: ''
description: Delegated zone used for the TSIG-signed updates (e.g. C(_acme.example.com.)).
traefik_acme_dns_nameserver:
type: str
default: ''
description: Nameserver lego talks to for the DNS challenge (C(host:port)).
traefik_acme_tsig_algorithm:
type: str
default: hmac-sha256
description: TSIG algorithm.
traefik_acme_tsig_key:
type: str
default: ''
description: TSIG key name.
traefik_acme_tsig_secret:
type: str
default: ''
description: TSIG secret (base64).
traefik_acme_propagation_timeout:
type: str
default: '120'
description: lego DNS propagation timeout in seconds.
traefik_acme_polling_interval:
type: str
default: '2'
description: lego DNS propagation polling interval in seconds.
traefik_acme_ttl:
type: str
default: '60'
description: TTL applied to the C(_acme-challenge) TXT records.
traefik_acme_tcp_only:
type: bool
default: false
description:
- Sets C(LEGO_EXPERIMENTAL_DNS_TCP_ONLY=true) on the container so SOA
resolution and propagation checks use TCP/53. Use when UDP/53 is
blocked or unreliable on the container egress path.
traefik_acme_disable_ans_checks:
type: bool
default: false
description:
- "Sets C(propagation.disableANSChecks) to true on the ACME resolver
in the static config, disabling lego's propagation check against
the zone's authoritative nameservers. Use when the SOA-listed NS
hostname resolves to an address the proxy host cannot reach; lego
still polls via the configured C(resolvers) list."
traefik_selfsigned_cert_dir:
type: path
description: Output directory for the self-signed bundle.
traefik_selfsigned_cert_days:
type: int
default: 365
description: Validity in days for the self-signed bundle.
traefik_selfsigned_common_name:
type: str
default: '*.local.test'
description: CN/SAN of the self-signed wildcard cert.
traefik_enable_dashboard:
type: bool
default: false
description: Expose the traefik dashboard.
traefik_dashboard_domain:
type: str
default: ''
description:
- When non-empty, the dashboard is published on this Host rule instead
of the insecure port 8080.
traefik_enable_access_logs:
type: bool
default: true
traefik_access_log_format:
type: str
choices: [common, json]
default: common
traefik_log_level:
type: str
choices: [DEBUG, INFO, WARN, ERROR, FATAL, PANIC]
default: INFO
traefik_network:
type: str
default: proxy
description: Docker network connecting traefik to its routable containers.
traefik_dmz_exposed_services:
type: list
elements: dict
default: []
description:
- In C(dmz) mode, services collected from backend host_vars are
published via the file provider. Each entry needs C(name),
C(domain), C(port); C(protocol) and C(backend_host) are optional.
options:
name:
type: str
required: true
domain:
type: str
required: true
port:
type: int
required: true
protocol:
type: str
choices: [http, https]
default: http
backend_host:
type: str
description: Override the auto-selected backend host.
traefik_services:
type: list
elements: dict
default: []
description:
- Services defined directly on the DMZ proxy (not auto-discovered
from a backend host). Each entry must set C(backend_host).
options:
name:
type: str
required: true
domain:
type: str
required: true
backend_host:
type: str
required: true
port:
type: int
required: true
protocol:
type: str
choices: [http, https]
default: http
traefik_backend_servers_to_proxy:
type: list
elements: str
default: []
description:
- In C(dmz) mode, explicit list of backend hosts the DMZ proxy
should aggregate exposed services from. Empty means all members
of the C(backend_servers) inventory group.

View file

@ -1,33 +1,26 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: your name
description: your role description
company: your company (optional)
author: digitalboard
description: Deploy Traefik v3 as a DMZ or backend reverse proxy via Docker Compose
company: Digitalboard
license: MIT-0
# 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
min_ansible_version: "2.14"
# 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)
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
min_ansible_version: 2.1
# 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.
galaxy_tags:
- traefik
- reverseproxy
- ingress
- docker
- digitalboard
dependencies: []

View file

@ -9,7 +9,18 @@
- name: Build service registry from backend servers (DMZ mode)
set_fact:
proxied_services: "{{ proxied_services | default([]) + hostvars[item].traefik_dmz_exposed_services | default([]) | map('combine', {'backend_host': hostvars[item].ansible_host | default(item)}) | list }}"
# Two-step merge so a service entry's own `backend_host` wins:
# entries that set it pass through unchanged, entries that don't
# get the backend host's ansible_host as fallback. The override
# lets a route target an internal FQDN covered by the backend
# cert's SANs instead of the raw IP (which would fail backend
# TLS verification at the proxy hop).
proxied_services: >-
{{
proxied_services | default([])
+ (hostvars[item].traefik_dmz_exposed_services | default([]) | selectattr('backend_host', 'defined') | list)
+ (hostvars[item].traefik_dmz_exposed_services | default([]) | rejectattr('backend_host', 'defined') | map('combine', {'backend_host': hostvars[item].ansible_host | default(item)}) | list)
}}
loop: "{{ _backend_servers | default([]) }}"
when: traefik_mode == 'dmz'

View file

@ -12,6 +12,9 @@ services:
RFC2136_PROPAGATION_TIMEOUT: "{{ traefik_acme_propagation_timeout }}"
RFC2136_POLLING_INTERVAL: "{{ traefik_acme_polling_interval }}"
RFC2136_TTL: "{{ traefik_acme_ttl }}"
{% if traefik_acme_tcp_only | default(false) %}
LEGO_EXPERIMENTAL_DNS_TCP_ONLY: "true"
{% endif %}
{% endif %}
ports:
- "80:80"
@ -30,6 +33,12 @@ services:
{% endif %}
networks:
- {{ traefik_network }}
{% if traefik_extra_hosts | default([]) | length > 0 %}
extra_hosts:
{% for h in traefik_extra_hosts %}
- "{{ h }}"
{% endfor %}
{% endif %}
networks:
{{ traefik_network }}:

View file

@ -48,6 +48,10 @@ certificatesResolvers:
provider: rfc2136
resolvers:
- "{{ traefik_acme_dns_nameserver }}"
{% if traefik_acme_disable_ans_checks | default(false) %}
propagation:
disableANSChecks: true
{% endif %}
{% endif %}
{% if traefik_use_ssl %}