Compare commits

..

1 commit

Author SHA1 Message Date
9ac6596063
chore: create empty boilerplate roles to setup local acme simulation
testing if this could allow us to simulate all the acme dns-challenge stuff using pebble and knot. Since we will be using the same in production

Signed-off-by: Bert-Jan Fikse <bert-jan@whatwedo.ch>
2026-04-02 11:52:21 +02:00
154 changed files with 996 additions and 7991 deletions

7
.gitignore vendored
View file

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

View file

@ -1,68 +1,3 @@
# Ansible Collection digitalboard.core # Ansible Collection - digitalboard.core
This collection bundles the Ansible roles used to deploy the Documentation for the collection.
[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

@ -19,16 +19,15 @@ readme: README.md
authors: authors:
- Bert-Jan Fikse <bert-jan@whatwedo.ch> - Bert-Jan Fikse <bert-jan@whatwedo.ch>
- Tobias Wüst <tobias.wuest@wksbern.ch> - Tobias Wüst <tobias.wuest@wksbern.ch>
- Simon Bärlocher <simon@whatwedo.ch>
### OPTIONAL but strongly recommended ### OPTIONAL but strongly recommended
# A short summary description of the collection # A short summary description of the collection
description: Ansible roles to deploy the Digitalboard self-hosted service platform (Docker Compose + Traefik + SSO) description: your collection description
# Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only # 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' # accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file'
license: license:
- MIT-0 - GPL-2.0-or-later
# The path to the license file for the collection. This path is relative to the root of the collection. This key is # 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' # mutually exclusive with 'license'
@ -36,36 +35,25 @@ license_file: ''
# A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character # 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' # 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 # 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 # collection label 'namespace.name'. The value is a version range
# L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version # L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version
# range specifiers can be set and are separated by ',' # 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 # The URL of the originating SCM repository
repository: https://git.digitalboard.ch/Digitalboard/digitalboard.core repository: https://git.digitalboard.ch/Digitalboard/digitalboard.core
# The URL to any online docs # The URL to any online docs
documentation: https://git.digitalboard.ch/Digitalboard/digitalboard.core documentation: http://docs.example.com
# The URL to the homepage of the collection/project # The URL to the homepage of the collection/project
homepage: https://git.digitalboard.ch/Digitalboard/digitalboard.core homepage: http://example.com
# The URL to the collection issue tracker # The URL to the collection issue tracker
issues: https://git.digitalboard.ch/Digitalboard/digitalboard.core/issues issues: http://example.com/issue/tracker
# A list of file glob-like patterns used to filter any files or directories that should not be included in the build # 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 # artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This

View file

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

View file

@ -1,32 +1,31 @@
# Collection Plugins — digitalboard.core # Collections Plugins Directory
This collection ships a small number of custom plugins that support the roles. This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that
They are addressed by their fully qualified name, `digitalboard.core.<name>`. 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.
## Filter plugins (`filter/`) Here is an example directory of the majority of plugins currently supported by Ansible:
`homarr_layout` — computes Homarr dashboard grid layouts (desktop / tablet / ```
mobile breakpoints) from a list of apps, returning a ready-to-render data └── plugins
structure for the SQL seed. Used by the `homarr` role. ├── action
├── become
```yaml ├── cache
- name: Compute Homarr app layouts ├── callback
ansible.builtin.set_fact: ├── cliconf
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" ├── connection
├── filter
├── httpapi
├── inventory
├── lookup
├── module_utils
├── modules
├── netconf
├── shell
├── strategy
├── terminal
├── test
└── vars
``` ```
## Lookup plugins (`lookup/`) A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.19/plugins/plugins.html).
`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,140 +0,0 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT-0
"""Custom Ansible filter plugin for computing Homarr grid layouts.
The Homarr SQL seed needs item_layout rows for three breakpoints
(desktop / tablet / mobile). Rather than embedding the packing
algorithm in Jinja with namespace gymnastics, this filter does the
computation in Python and hands the seed template a ready-to-render
data structure.
Usage in tasks:
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
The result is a dict with two keys:
apps original homarr_apps in order, each enriched with
'desktop', 'tablet', 'mobile' sub-dicts of
{'x', 'y', 'w', 'h'} ready for SQL templating.
section_height dict with 'desktop', 'tablet', 'mobile' keys
giving the minimum height (in grid cells) the
parent section must have to fit all tiles.
"""
from ansible.errors import AnsibleFilterError
def _pack(apps, cols):
"""Greedy left-to-right packing into a fixed-column grid.
Width values larger than the grid are clamped to the grid width
rather than overflowing so a tile declared with width=8 still
renders on the 6-column tablet grid (as a full-width tile) and on
the 2-column mobile grid (as a full-width tile) without breaking
the layout.
Returns (placements, total_height) where placements is a list of
{'id', 'x', 'y', 'w', 'h'} dicts, one per input app, in the same
order. total_height is the y-coordinate of the bottom of the last
occupied row (i.e. max(y + h) across placements).
"""
x = 0
y = 0
row_h = 0
max_y = 0
placements = []
for app in apps:
w = min(int(app.get('width', 1)), cols)
h = int(app.get('height', 1))
# Wrap to the next row when the tile would overflow the grid.
if x + w > cols:
x = 0
y += row_h
row_h = 0
placements.append({
'id': app['id'],
'x': x,
'y': y,
'w': w,
'h': h,
})
x += w
if h > row_h:
row_h = h
if y + h > max_y:
max_y = y + h
return placements, max_y
def homarr_compute_layouts(apps, desktop_cols=10, tablet_cols=6,
mobile_cols=2):
"""Compute responsive layouts for a list of Homarr apps.
Input validation is intentionally strict a malformed apps list
should fail the play with a clear message rather than produce a
broken SQL seed and a silently misconfigured dashboard.
Note: uniqueness of app ids is NOT checked here. The role's
`Validate homarr_apps have unique ids` assert task runs earlier
and is the single source of truth for that check.
"""
if not isinstance(apps, list):
raise AnsibleFilterError(
"homarr_compute_layouts: expected a list of apps, "
"got {0}".format(type(apps).__name__)
)
for index, app in enumerate(apps):
if not isinstance(app, dict):
raise AnsibleFilterError(
"homarr_compute_layouts: app at index {0} is not a "
"dict (got {1})".format(index, type(app).__name__)
)
for required in ('id', 'width'):
if required not in app:
raise AnsibleFilterError(
"homarr_compute_layouts: app at index {0} is "
"missing required key '{1}'".format(index, required)
)
desktop, h_desktop = _pack(apps, desktop_cols)
tablet, h_tablet = _pack(apps, tablet_cols)
mobile, h_mobile = _pack(apps, mobile_cols)
enriched = []
for src, d, t, m in zip(apps, desktop, tablet, mobile):
enriched.append({
**src,
'desktop': {'x': d['x'], 'y': d['y'], 'w': d['w'], 'h': d['h']},
'tablet': {'x': t['x'], 'y': t['y'], 'w': t['w'], 'h': t['h']},
'mobile': {'x': m['x'], 'y': m['y'], 'w': m['w'], 'h': m['h']},
})
# Floor section height at 1 so the section stays visible even
# when homarr_apps is empty.
return {
'apps': enriched,
'section_height': {
'desktop': max(h_desktop, 1),
'tablet': max(h_tablet, 1),
'mobile': max(h_mobile, 1),
},
}
class FilterModule(object):
"""Ansible filter plugin entry point."""
def filters(self):
return {
'homarr_compute_layouts': homarr_compute_layouts,
}

View file

@ -1,43 +1,38 @@
# 389ds Role Name
=========
Deploys [389 Directory Server](https://www.port389.org/) (`389ds/dirsrv`) A brief description of the role goes here.
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
------------
- Docker and Docker Compose on the target host (e.g. via 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.
`digitalboard.core.base`)
- Ansible collection: `community.docker`
## Role variables Role Variables
--------------
| Variable | Default | Description | 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.
| --- | --- | --- |
| `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. |
## Example Dependencies
------------
```yaml 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.
- hosts: directory
become: true 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: roles:
- role: digitalboard.core.389ds - { role: username.rolename, x: 42 }
vars:
ds389_suffix: "dc=example,dc=org"
ds389_root_password: "{{ vault_ds389_root_password }}"
```
## License License
-------
MIT-0 BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,26 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy 389 Directory Server (LDAP) via Docker Compose description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- 389ds
- ldap # If this a Container Enabled role, provide the minimum Ansible Container version.
- directory # min_ansible_container_version:
- docker
- digitalboard galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] 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,136 +1,28 @@
# Authentik # Authentik
Deploys [authentik](https://goauthentik.io) (server + worker + Postgres) Deploys Authentik identity provider with Docker Compose.
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 ## Variables
Full spec with types and defaults: `meta/argument_specs.yml`. The most See `defaults/main.yml` for all available variables.
common overrides:
### Service ## Blueprints
- `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: The role renders blueprints for:
- Local users (`authentik_local_users`) - Local users (`authentik_local_users`)
- Groups (`authentik_groups`)
- OIDC applications (`authentik_oidc_apps`) - OIDC applications (`authentik_oidc_apps`)
- Proxy applications (`authentik_proxy_apps`) - Proxy applications (`authentik_proxy_apps`)
- Proxy outposts (`authentik_proxy_outposts`) - Proxy outposts (`authentik_proxy_outposts`)
- LDAP applications (`authentik_ldap_apps`)
- LDAP outpost (`authentik_ldap_outpost`)
- Entra ID sources (`authentik_entra_sources`) - Entra ID sources (`authentik_entra_sources`)
- Login-screen source visibility (`authentik_login_sources`) - Login screen sources (`authentik_login_source_ids`)
Secrets are passed via the `authentik_blueprint_env` env-var indirection Secrets are passed via `authentik_blueprint_env` using environment variable references.
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 ## Removing resources
Move slugs from the active list to the matching removal list: To remove resources from Authentik, move slugs to the removal lists:
- `authentik_removed_oidc_apps` - `authentik_removed_oidc_apps`
- `authentik_removed_proxy_apps` - `authentik_removed_proxy_apps`
- `authentik_removed_local_users` - `authentik_removed_local_users`
After authentik has applied the deletion blueprint, remove the slug After confirming deletion, remove the slug from the list.
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,21 +12,8 @@ authentik_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ authentik_servic
authentik_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ authentik_service_name }}" authentik_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ authentik_service_name }}"
# Authentik service configuration # Authentik service configuration
# FQDNs the authentik router accepts. The first entry is the canonical authentik_domain: "authentik.local.test"
# domain; further entries cover internal *.int.* names used for authentik_image: "ghcr.io/goauthentik/server:2025.12.0"
# 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_port: 9000
authentik_secret_key: "changeme-generate-a-random-string" authentik_secret_key: "changeme-generate-a-random-string"
@ -70,29 +57,11 @@ authentik_proxy_outposts: []
# authentik_host_browser: "https://authentik.local.test/" # authentik_host_browser: "https://authentik.local.test/"
# log_level: "info" # log_level: "info"
authentik_ldap_apps: []
# - slug: ldap
# name: LDAP
# base_dn: "dc=local,dc=test"
# search_mode: cached # cached | direct
# bind_mode: cached # cached | direct
# search_group: null # optional: group name whose members can search
# certificate: null # optional: certificate name for LDAPS
# uid_start_number: 2000
# gid_start_number: 4000
authentik_ldap_outpost: {}
# name: "ldap-outpost"
# token: "changeme" # known token for outpost authentication
# config:
# authentik_host: "https://authentik.local.test/"
# log_level: "info"
authentik_oidc_apps: [] authentik_oidc_apps: []
# - slug: grafana # - slug: grafana
# name: Grafana # name: Grafana
# client_id: "grafana" # client_id_env: GRAFANA_OIDC_CLIENT_ID
# client_secret: "changeme" # client_secret_env: GRAFANA_OIDC_CLIENT_SECRET
# redirect_uris: # redirect_uris:
# - url: "https://grafana.example.com/login/generic_oauth" # - url: "https://grafana.example.com/login/generic_oauth"
# matching_mode: strict # matching_mode: strict
@ -102,14 +71,21 @@ authentik_oidc_apps: []
# invalidation_slug: default-provider-invalidation-flow # invalidation_slug: default-provider-invalidation-flow
# scopes: [openid, email, profile, offline_access] # scopes: [openid, email, profile, offline_access]
authentik_blueprint_env: []
# GRAFANA_OIDC_CLIENT_ID: "grafana"
# GRAFANA_OIDC_CLIENT_SECRET: "{{ vault_grafana_oidc_secret }}"
# ENTRA_TENANT_ID: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# ENTRA_CLIENT_ID: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# ENTRA_CLIENT_SECRET: "{{ vault_entra_client_secret }}"
# Oauth sources # Oauth sources
authentik_entra_sources: [] authentik_entra_sources: []
# - slug: entra-id # - slug: entra-id
# name: "Login with Entra" # name: "Login with Entra"
# tenant_mode: single # single | common # tenant_mode: single # single | common
# tenant_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # tenant_id_env: ENTRA_TENANT_ID
# client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # client_id_env: ENTRA_CLIENT_ID
# client_secret: "changeme" # client_secret_env: ENTRA_CLIENT_SECRET
# scopes: # scopes:
# - openid # - openid
# - profile # - profile
@ -129,19 +105,12 @@ authentik_login_user_fields:
- username - username
- email - email
# Groups to provision
authentik_groups: []
# - name: admins
# - name: editors
# is_superuser: false
# parent: null
# Local users to provision # Local users to provision
authentik_local_users: [] authentik_local_users: []
# - username: admin # - username: admin
# name: "Admin User" # name: "Admin User"
# email: "admin@example.com" # email: "admin@example.com"
# password: "changeme" # password_env: AUTHENTIK_ADMIN_PASSWORD # reference env var in authentik_blueprint_env
# is_active: true # is_active: true
# groups: # groups:
# - authentik Admins # - authentik Admins

View file

@ -1,193 +0,0 @@
---
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,28 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy authentik (server + worker + Postgres) via Docker Compose with blueprint-provisioned resources description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- authentik
- oidc # If this a Container Enabled role, provide the minimum Ansible Container version.
- sso # min_ansible_container_version:
- idp
- docker galaxy_tags: []
- traefik # List tags for your role here, one per line. A tag is a keyword that describes
- digitalboard # and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -9,17 +9,17 @@
register: existing_blueprints register: existing_blueprints
- name: Build list of expected blueprint files - name: Build list of expected blueprint files
vars:
_oidc: "{{ authentik_oidc_apps | map(attribute='slug') | map('regex_replace', '^', '50-oidc-') | map('regex_replace', '$', '.yaml') | list }}"
_ldap: "{{ authentik_ldap_apps | map(attribute='slug') | map('regex_replace', '^', '55-ldap-') | map('regex_replace', '$', '.yaml') | list }}"
_proxy: "{{ authentik_proxy_apps | map(attribute='slug') | map('regex_replace', '^', '60-proxy-') | map('regex_replace', '$', '.yaml') | list }}"
_outpost: "{{ authentik_proxy_outposts | map(attribute='name') | map('regex_replace', '^', '70-outpost-') | map('regex_replace', '$', '.yaml') | list }}"
_entra: "{{ authentik_entra_sources | map(attribute='slug') | map('regex_replace', '^', '40-source-entra-') | map('regex_replace', '$', '.yaml') | list }}"
_ldap_out: "{{ ['75-outpost-ldap.yaml'] if authentik_ldap_outpost.name is defined else [] }}"
_users: "{{ ['10-local-users.yaml'] if (authentik_local_users | length > 0 or authentik_groups | length > 0) else [] }}"
_cleanup: "{{ ['00-cleanup.yaml'] if (authentik_removed_oidc_apps + authentik_removed_proxy_apps + authentik_removed_local_users) | length > 0 else [] }}"
set_fact: set_fact:
expected_blueprints: "{{ _oidc + _ldap + _proxy + _outpost + _entra + ['45-login-sources.yaml'] + _ldap_out + _users + _cleanup }}" expected_blueprints: >-
{{
(authentik_oidc_apps | map(attribute='slug') | map('regex_replace', '^(.*)$', '50-oidc-\1.yaml') | list) +
(authentik_proxy_apps | map(attribute='slug') | map('regex_replace', '^(.*)$', '60-proxy-\1.yaml') | list) +
(authentik_proxy_outposts | map(attribute='name') | map('regex_replace', '^(.*)$', '70-outpost-\1.yaml') | list) +
(authentik_entra_sources | map(attribute='slug') | map('regex_replace', '^(.*)$', '40-source-entra-\1.yaml') | list) +
['45-login-sources.yaml'] +
((authentik_local_users | length > 0) | ternary(['10-local-users.yaml'], [])) +
(((authentik_removed_oidc_apps | length > 0) or (authentik_removed_proxy_apps | length > 0) or (authentik_removed_local_users | length > 0)) | ternary(['00-cleanup.yaml'], []))
}}
- name: Remove stale blueprint files - name: Remove stale blueprint files
file: file:
@ -36,14 +36,6 @@
loop: "{{ authentik_oidc_apps }}" loop: "{{ authentik_oidc_apps }}"
register: oidc_templates register: oidc_templates
- name: Render LDAP blueprints
ansible.builtin.template:
src: blueprints/blueprint-ldap-app.yaml.j2
dest: "{{ authentik_docker_volume_dir }}/blueprints/55-ldap-{{ item.slug }}.yaml"
mode: "0644"
loop: "{{ authentik_ldap_apps }}"
register: ldap_templates
- name: Render Proxy blueprints - name: Render Proxy blueprints
ansible.builtin.template: ansible.builtin.template:
src: blueprints/blueprint-proxy-app.yaml.j2 src: blueprints/blueprint-proxy-app.yaml.j2
@ -60,14 +52,6 @@
loop: "{{ authentik_proxy_outposts }}" loop: "{{ authentik_proxy_outposts }}"
register: outpost_bp register: outpost_bp
- name: Render LDAP outpost blueprint
ansible.builtin.template:
src: blueprints/outpost-ldap.yaml.j2
dest: "{{ authentik_docker_volume_dir }}/blueprints/75-outpost-ldap.yaml"
mode: "0644"
when: authentik_ldap_outpost.name is defined
register: ldap_outpost_bp
- name: Render Entra source blueprints - name: Render Entra source blueprints
ansible.builtin.template: ansible.builtin.template:
src: blueprints/blueprint-source-entra.yaml.j2 src: blueprints/blueprint-source-entra.yaml.j2
@ -88,7 +72,7 @@
src: blueprints/blueprint-local-users.yaml.j2 src: blueprints/blueprint-local-users.yaml.j2
dest: "{{ authentik_docker_volume_dir }}/blueprints/10-local-users.yaml" dest: "{{ authentik_docker_volume_dir }}/blueprints/10-local-users.yaml"
mode: "0644" mode: "0644"
when: authentik_local_users | length > 0 or authentik_groups | length > 0 when: authentik_local_users | length > 0
register: local_users_bp register: local_users_bp
- name: Render cleanup blueprint - name: Render cleanup blueprint
@ -104,10 +88,8 @@
blueprints_changed: >- blueprints_changed: >-
{{ {{
(oidc_templates is defined and (oidc_templates.results | selectattr('changed') | list | length > 0)) (oidc_templates is defined and (oidc_templates.results | selectattr('changed') | list | length > 0))
or (ldap_templates is defined and (ldap_templates.results | selectattr('changed') | list | length > 0))
or (proxy_templates is defined and (proxy_templates.results | selectattr('changed') | list | length > 0)) or (proxy_templates is defined and (proxy_templates.results | selectattr('changed') | list | length > 0))
or (outpost_bp is defined and (outpost_bp.results | selectattr('changed') | list | length > 0)) or (outpost_bp is defined and (outpost_bp.results | selectattr('changed') | list | length > 0))
or (ldap_outpost_bp.changed | default(false))
or (entra_bp is defined and (entra_bp.results | selectattr('changed') | list | length > 0)) or (entra_bp is defined and (entra_bp.results | selectattr('changed') | list | length > 0))
or (login_bp is defined and login_bp.changed) or (login_bp is defined and login_bp.changed)
or (local_users_bp.changed | default(false)) or (local_users_bp.changed | default(false))

View file

@ -2,18 +2,44 @@
--- ---
# tasks file for authentik # tasks file for authentik
- name: Create authentik directories - name: Create docker compose directory
file: file:
path: "{{ item }}" path: "{{ authentik_docker_compose_dir }}"
state: directory state: directory
mode: '0755' mode: '0755'
loop:
- "{{ authentik_docker_compose_dir }}" - name: Create authentik data directory
- "{{ authentik_docker_volume_dir }}/data" file:
- "{{ authentik_docker_volume_dir }}/certs" path: "{{ authentik_docker_volume_dir }}/data"
- "{{ authentik_docker_volume_dir }}/templates" state: directory
- "{{ authentik_docker_volume_dir }}/postgresql" mode: '0755'
- "{{ authentik_docker_volume_dir }}/blueprints"
- name: Create authentik certs directory
file:
path: "{{ authentik_docker_volume_dir }}/certs"
state: directory
mode: '0755'
- name: Create authentik templates directory
file:
path: "{{ authentik_docker_volume_dir }}/templates"
state: directory
mode: '0755'
- name: Create postgres data directory
file:
path: "{{ authentik_docker_volume_dir }}/postgresql"
state: directory
mode: '0755'
- name: Create blueprints directory
file:
path: "{{ authentik_docker_volume_dir }}/blueprints"
state: directory
mode: '0755'
- name: Render blueprints
import_tasks: blueprints.yml
- name: Create docker-compose file for authentik - name: Create docker-compose file for authentik
template: template:
@ -25,46 +51,6 @@
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ authentik_docker_compose_dir }}" project_src: "{{ authentik_docker_compose_dir }}"
state: present state: present
recreate: "{{ blueprints_changed | ternary('always', 'auto') }}"
wait: true wait: true
wait_timeout: 300 wait_timeout: 300
- name: Render blueprints
import_tasks: blueprints.yml
- name: Render blueprint wait script
template:
src: wait-for-blueprints.py.j2
dest: "{{ authentik_docker_volume_dir }}/data/wait-for-blueprints.py"
mode: '0644'
- name: Wait for custom blueprints to be applied
community.docker.docker_compose_v2_exec:
project_src: "{{ authentik_docker_compose_dir }}"
service: server
command: ak shell -c "exec(open('/data/wait-for-blueprints.py').read())"
register: blueprint_wait_result
changed_when: "'changed' in blueprint_wait_result.stdout"
retries: 30
delay: 10
until: blueprint_wait_result.rc == 0
when: blueprints_changed
- name: Render LDAP outpost token script
template:
src: set-outpost-token.py.j2
dest: "{{ authentik_docker_volume_dir }}/data/set-outpost-token.py"
mode: '0644'
when: authentik_ldap_outpost.name is defined
register: ldap_token_script
- name: Set known token for LDAP outpost
community.docker.docker_compose_v2_exec:
project_src: "{{ authentik_docker_compose_dir }}"
service: server
command: ak shell -c "exec(open('/data/set-outpost-token.py').read())"
register: ldap_token_result
changed_when: "'changed' in ldap_token_result.stdout"
retries: 30
delay: 10
until: ldap_token_result.rc == 0
when: authentik_ldap_outpost.name is defined and (blueprints_changed or ldap_token_script.changed)

View file

@ -1,124 +0,0 @@
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: "ldap-{{ item.slug }}"
labels:
blueprints.goauthentik.io/instantiate: "true"
blueprints.goauthentik.io/description: "LDAP provider + application for {{ item.slug }}"
entries:
# Simple password-only flow for LDAP bind (no browser policies)
- model: authentik_stages_password.passwordstage
id: ldap-password-stage
identifiers:
name: ldap-bind-password
attrs:
name: ldap-bind-password
backends:
- authentik.core.auth.InbuiltBackend
- authentik.core.auth.TokenBackend
- authentik.sources.ldap.auth.LDAPBackend
- model: authentik_stages_identification.identificationstage
id: ldap-identification-stage
identifiers:
name: ldap-bind-identification
attrs:
name: ldap-bind-identification
user_fields:
- username
- email
password_stage: !KeyOf ldap-password-stage
- model: authentik_stages_user_login.userloginstage
id: ldap-login-stage
identifiers:
name: ldap-bind-login
attrs:
name: ldap-bind-login
- model: authentik_flows.flow
id: ldap-bind-flow
identifiers:
slug: ldap-bind
attrs:
name: LDAP Bind
slug: ldap-bind
title: LDAP Bind
designation: authentication
authentication: none
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf ldap-bind-flow
stage: !KeyOf ldap-identification-stage
order: 0
attrs:
target: !KeyOf ldap-bind-flow
stage: !KeyOf ldap-identification-stage
order: 0
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf ldap-bind-flow
stage: !KeyOf ldap-login-stage
order: 10
attrs:
target: !KeyOf ldap-bind-flow
stage: !KeyOf ldap-login-stage
order: 10
{% if item.search_group is defined and item.search_group %}
- model: authentik_rbac.role
id: ldap-search-role-{{ item.slug }}
identifiers:
name: ldap-search-{{ item.slug }}
attrs:
name: ldap-search-{{ item.slug }}
{% endif %}
- model: authentik_providers_ldap.ldapprovider
id: ldap-provider-{{ item.slug }}
identifiers:
name: {{ item.name }}
attrs:
name: {{ item.name }}
base_dn: "{{ item.base_dn }}"
authorization_flow: !KeyOf ldap-bind-flow
invalidation_flow: !Find [authentik_flows.flow, [slug, {{ item.invalidation_flow_slug | default('default-provider-invalidation-flow') }}]]
authentication_flow: !KeyOf ldap-bind-flow
search_mode: {{ item.search_mode | default('cached') }}
bind_mode: {{ item.bind_mode | default('direct') }}
{% if item.certificate is defined and item.certificate %}
certificate: !Find [authentik_crypto.certificatekeypair, [name, {{ item.certificate }}]]
{% endif %}
{% if item.uid_start_number is defined %}
uid_start_number: {{ item.uid_start_number }}
{% endif %}
{% if item.gid_start_number is defined %}
gid_start_number: {{ item.gid_start_number }}
{% endif %}
{% if item.search_group is defined and item.search_group %}
permissions:
- permission: authentik_providers_ldap.search_full_directory
role: !KeyOf ldap-search-role-{{ item.slug }}
{% endif %}
{% if item.search_group is defined and item.search_group %}
# Assign the LDAP search role to the search group
- model: authentik_core.group
identifiers:
name: {{ item.search_group }}
attrs:
roles:
- !KeyOf ldap-search-role-{{ item.slug }}
{% endif %}
- model: authentik_core.application
id: app-{{ item.slug }}
identifiers:
slug: {{ item.slug }}
attrs:
name: "{{ item.name | default(item.slug) }}"
slug: {{ item.slug }}
provider: !KeyOf ldap-provider-{{ item.slug }}

View file

@ -4,24 +4,9 @@ metadata:
name: "local-users" name: "local-users"
labels: labels:
blueprints.goauthentik.io/instantiate: "true" blueprints.goauthentik.io/instantiate: "true"
blueprints.goauthentik.io/description: "Local groups and user accounts" blueprints.goauthentik.io/description: "Local user accounts"
entries: entries:
{% for group in authentik_groups %}
- model: authentik_core.group
id: group-{{ group.name | regex_replace('[^a-zA-Z0-9]', '-') }}
identifiers:
name: {{ group.name }}
attrs:
name: {{ group.name }}
{% if group.is_superuser is defined %}
is_superuser: {{ group.is_superuser | lower }}
{% endif %}
{% if group.parent is defined and group.parent %}
parent: !Find [authentik_core.group, [name, {{ group.parent }}]]
{% endif %}
{% endfor %}
{% for user in authentik_local_users %} {% for user in authentik_local_users %}
- model: authentik_core.user - model: authentik_core.user
id: user-{{ user.username }} id: user-{{ user.username }}
@ -32,8 +17,8 @@ entries:
name: "{{ user.name | default(user.username) }}" name: "{{ user.name | default(user.username) }}"
email: "{{ user.email | default('') }}" email: "{{ user.email | default('') }}"
is_active: {{ user.is_active | default(true) | lower }} is_active: {{ user.is_active | default(true) | lower }}
{% if user.password is defined %} {% if user.password_env is defined %}
password: "{{ user.password }}" password: !Env {{ user.password_env }}
{% endif %} {% endif %}
{% if user.groups is defined and user.groups | length > 0 %} {% if user.groups is defined and user.groups | length > 0 %}
groups: groups:

View file

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

View file

@ -13,11 +13,9 @@ entries:
name: {{ item.slug }} name: {{ item.slug }}
attrs: attrs:
name: {{ item.slug }} name: {{ item.slug }}
client_type: {{ item.client_type | default('confidential') }} client_type: confidential
client_id: "{{ item.client_id }}" client_id: !Env {{ item.client_id_env }}
{% if item.client_type | default('confidential') == 'confidential' %} client_secret: !Env {{ item.client_secret_env }}
client_secret: "{{ item.client_secret }}"
{% endif %}
redirect_uris: redirect_uris:
{% for ru in item.redirect_uris %} {% for ru in item.redirect_uris %}

View file

@ -20,16 +20,6 @@ entries:
internal_host: "{{ item.internal_host }}" internal_host: "{{ item.internal_host }}"
external_host: "{{ item.external_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 %} {% if item.skip_path_regex is defined and item.skip_path_regex|length > 0 %}
skip_path_regex: | skip_path_regex: |
@ -44,20 +34,3 @@ entries:
name: "{{ item.name | default(item.slug) }}" name: "{{ item.name | default(item.slug) }}"
slug: {{ item.slug }} slug: {{ item.slug }}
provider: !KeyOf proxy-provider-{{ 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

@ -15,12 +15,12 @@ entries:
name: "{{ item.name | default('Microsoft Entra ID') }}" name: "{{ item.name | default('Microsoft Entra ID') }}"
slug: {{ item.slug }} slug: {{ item.slug }}
# Authentik's OAuth sources support vendor-specific types. # Authentiks OAuth sources support vendor-specific types.
# Entra guide calls it "Entra ID OAuth Source". # Entra guide calls it “Entra ID OAuth Source”.
provider_type: entraid provider_type: entraid
consumer_key: "{{ item.client_id }}" consumer_key: !Env {{ item.client_id_env }}
consumer_secret: "{{ item.client_secret }}" consumer_secret: !Env {{ item.client_secret_env }}
scopes: scopes:
{% for s in (item.scopes | default(['openid','profile','email'])) %} {% for s in (item.scopes | default(['openid','profile','email'])) %}
@ -28,10 +28,10 @@ entries:
{% endfor %} {% endfor %}
{% if (item.tenant_mode | default('single')) == 'single' %} {% if (item.tenant_mode | default('single')) == 'single' %}
authorization_url: "https://login.microsoftonline.com/{{ item.tenant_id }}/oauth2/v2.0/authorize" authorization_url: !Format ["https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", !Env {{ item.tenant_id_env }}]
access_token_url: "https://login.microsoftonline.com/{{ item.tenant_id }}/oauth2/v2.0/token" access_token_url: !Format ["https://login.microsoftonline.com/%s/oauth2/v2.0/token", !Env {{ item.tenant_id_env }}]
profile_url: "https://graph.microsoft.com/v1.0/me" profile_url: "https://graph.microsoft.com/v1.0/me"
oidc_jwks_url: "https://login.microsoftonline.com/{{ item.tenant_id }}/discovery/v2.0/keys" oidc_jwks_url: !Format ["https://login.microsoftonline.com/%s/discovery/v2.0/keys", !Env {{ item.tenant_id_env }}]
{% else %} {% else %}
authorization_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" authorization_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
access_token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token" access_token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token"

View file

@ -1,27 +0,0 @@
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
metadata:
name: "outpost-{{ authentik_ldap_outpost.name }}"
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_outposts.outpost
identifiers:
name: "{{ authentik_ldap_outpost.name }}"
attrs:
name: "{{ authentik_ldap_outpost.name }}"
type: ldap
service_connection: null
providers:
{% for app in authentik_ldap_apps %}
- !Find [authentik_providers_ldap.ldapprovider, [name, {{ app.name }}]]
{% endfor %}
{% if authentik_ldap_outpost.config is defined %}
config:
{% for k, v in authentik_ldap_outpost.config.items() %}
{{ k }}: {{ v | tojson }}
{% endfor %}
{% endif %}

View file

@ -35,6 +35,11 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: {{ authentik_postgres_password }} AUTHENTIK_POSTGRESQL__PASSWORD: {{ authentik_postgres_password }}
AUTHENTIK_LOG_LEVEL: {{ authentik_log_level }} AUTHENTIK_LOG_LEVEL: {{ authentik_log_level }}
AUTHENTIK_ERROR_REPORTING__ENABLED: "{{ authentik_error_reporting_enabled | lower }}" AUTHENTIK_ERROR_REPORTING__ENABLED: "{{ authentik_error_reporting_enabled | lower }}"
{% if authentik_blueprint_env|length > 0 %}
{% for k, v in authentik_blueprint_env.items() %}
{{ k }}: "{{ v }}"
{% endfor %}
{% endif %}
volumes: volumes:
- {{ authentik_docker_volume_dir }}/blueprints:/blueprints/custom - {{ authentik_docker_volume_dir }}/blueprints:/blueprints/custom
- {{ authentik_docker_volume_dir }}/data:/data - {{ authentik_docker_volume_dir }}/data:/data
@ -43,58 +48,19 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks: networks:
{{ authentik_backend_network }}: {} - {{ authentik_backend_network }}
# No alias for the public FQDN here: that would shadow `/etc/hosts` - {{ authentik_traefik_network }}
# 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: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network={{ authentik_traefik_network }} - traefik.docker.network={{ authentik_traefik_network }}
- 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 }}.rule=Host(`{{ authentik_domain }}`)
- traefik.http.routers.{{ authentik_service_name }}.service={{ authentik_service_name }}
{% if authentik_use_ssl %} {% if authentik_use_ssl %}
- traefik.http.routers.{{ authentik_service_name }}.entrypoints=websecure - traefik.http.routers.{{ authentik_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ authentik_service_name }}.tls=true - 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 %} {% else %}
- traefik.http.routers.{{ authentik_service_name }}.entrypoints=web - traefik.http.routers.{{ authentik_service_name }}.entrypoints=web
{% endif %} {% endif %}
- traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }} - 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: worker:
image: {{ authentik_image }} image: {{ authentik_image }}
@ -109,6 +75,11 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: {{ authentik_postgres_password }} AUTHENTIK_POSTGRESQL__PASSWORD: {{ authentik_postgres_password }}
AUTHENTIK_LOG_LEVEL: {{ authentik_log_level }} AUTHENTIK_LOG_LEVEL: {{ authentik_log_level }}
AUTHENTIK_ERROR_REPORTING__ENABLED: "{{ authentik_error_reporting_enabled | lower }}" AUTHENTIK_ERROR_REPORTING__ENABLED: "{{ authentik_error_reporting_enabled | lower }}"
{% if authentik_blueprint_env|length > 0 %}
{% for k, v in authentik_blueprint_env.items() %}
{{ k }}: "{{ v }}"
{% endfor %}
{% endif %}
volumes: volumes:
- {{ authentik_docker_volume_dir }}/data:/data - {{ authentik_docker_volume_dir }}/data:/data
- {{ authentik_docker_volume_dir }}/certs:/certs - {{ authentik_docker_volume_dir }}/certs:/certs

View file

@ -1,10 +0,0 @@
from authentik.outposts.models import Outpost
from authentik.core.models import Token
o = Outpost.objects.get(name='{{ authentik_ldap_outpost.name }}')
t = Token.objects.get(identifier=o.token_identifier)
if t.key != '{{ authentik_ldap_outpost.token }}':
t.key = '{{ authentik_ldap_outpost.token }}'
t.save(update_fields=['key'])
print('changed')
else:
print('ok')

View file

@ -1,20 +0,0 @@
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
failed = list(BlueprintInstance.objects.filter(enabled=True, path__startswith="custom/").exclude(status="successful").order_by("path"))
if not failed:
print("ok")
else:
for bp in failed:
content = bp.retrieve()
importer = Importer.from_string(content)
valid, _ = importer.validate()
if valid:
importer.apply()
bp.status = "successful"
bp.save()
still_failed = BlueprintInstance.objects.filter(enabled=True, path__startswith="custom/").exclude(status="successful")
if still_failed.exists():
names = ", ".join(bp.name for bp in still_failed)
raise Exception(f"Blueprints still failing: {names}")
print("changed")

View file

@ -1,44 +1,38 @@
# authentik_outpost_ldap Role Name
=========
Deploys an [authentik](https://goauthentik.io) LDAP outpost via Docker A brief description of the role goes here.
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.
The outpost connects back to an authentik server using an outpost token Requirements
issued in the authentik admin interface. The image version must match ------------
the authentik server version.
## 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 Role Variables
`digitalboard.core.base`) --------------
- Ansible collection: `community.docker`
## 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 | Dependencies
| --- | --- | --- | ------------
| `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. |
## 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 Example Playbook
- hosts: directory ----------------
become: true
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: roles:
- role: digitalboard.core.authentik_outpost_ldap - { role: username.rolename, x: 42 }
vars:
authentik_outpost_ldap_host: "https://auth.example.com"
authentik_outpost_ldap_token: "{{ vault_authentik_ldap_outpost_token }}"
```
## License License
-------
MIT-0 BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,26 +1,3 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
--- ---
# defaults file for authentik_outpost_ldap # defaults file for authentik_outpost_ldap
# Base directory configuration (inherited from base role or defined here)
docker_compose_base_dir: /etc/docker/compose
docker_volume_base_dir: /srv/data
# Service configuration
authentik_outpost_ldap_service_name: authentik-outpost-ldap
authentik_outpost_ldap_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ authentik_outpost_ldap_service_name }}"
# Container image (must match authentik server version)
authentik_outpost_ldap_image: "ghcr.io/goauthentik/ldap:2026.2.2"
# Connection to authentik server
authentik_outpost_ldap_host: "https://authentik.local.test"
authentik_outpost_ldap_token: "changeme"
authentik_outpost_ldap_insecure: "true"
# Dedicated network for LDAP clients (nextcloud, opencloud, etc.)
authentik_outpost_ldap_network: "ldap"
# Extra hosts for DNS resolution within the container
authentik_outpost_ldap_extra_hosts: []
# - "authentik.local.test:192.168.56.11"

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy an authentik LDAP outpost via Docker Compose for applications that cannot use OIDC description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- authentik
- ldap # If this a Container Enabled role, provide the minimum Ansible Container version.
- outpost # min_ansible_container_version:
- sso
- docker galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] 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,31 +1,3 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
--- ---
# tasks file for authentik_outpost_ldap # tasks file for authentik_outpost_ldap
- name: Create LDAP network
community.docker.docker_network:
name: "{{ authentik_outpost_ldap_network }}"
state: present
- name: Create docker compose directory
file:
path: "{{ authentik_outpost_ldap_docker_compose_dir }}"
state: directory
mode: '0755'
- name: Create docker-compose file for authentik LDAP outpost
template:
src: docker-compose.yml.j2
dest: "{{ authentik_outpost_ldap_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
- name: Start authentik LDAP outpost container
community.docker.docker_compose_v2:
project_src: "{{ authentik_outpost_ldap_docker_compose_dir }}"
state: present
wait: true
wait_timeout: 120
retries: 3
delay: 15
register: result
until: result is not failed

View file

@ -1,27 +0,0 @@
services:
ldap:
image: {{ authentik_outpost_ldap_image }}
restart: unless-stopped
environment:
AUTHENTIK_HOST: {{ authentik_outpost_ldap_host }}
AUTHENTIK_TOKEN: {{ authentik_outpost_ldap_token }}
AUTHENTIK_INSECURE: "{{ authentik_outpost_ldap_insecure }}"
{% if authentik_outpost_ldap_extra_hosts | length > 0 %}
extra_hosts:
{% for host in authentik_outpost_ldap_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
networks:
- {{ authentik_outpost_ldap_network }}
{% if authentik_outpost_ldap_authentik_network is defined %}
- {{ authentik_outpost_ldap_authentik_network }}
{% endif %}
networks:
{{ authentik_outpost_ldap_network }}:
external: true
{% if authentik_outpost_ldap_authentik_network is defined %}
{{ authentik_outpost_ldap_authentik_network }}:
external: true
{% endif %}

View file

@ -1,45 +1,38 @@
# base Role Name
=========
Host baseline for the Digitalboard platform. Installs Docker (engine, A brief description of the role goes here.
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.
This role is intended to run first on every host, before any Requirements
service role. ------------
## What it does 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.
- Installs Docker prerequisites (`apt-transport-https`, `ca-certificates`, Role Variables
`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`.
This role defines the shared directory-layout 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.
(`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.
## Role variables Dependencies
------------
| Variable | Default | Description | 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.
| --- | --- | --- |
| `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 Example Playbook
----------------
```yaml Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: all
become: true - hosts: servers
roles: roles:
- digitalboard.core.base - { role: username.rolename, x: 42 }
```
## License License
-------
MIT-0 BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,25 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Host baseline — install Docker, required apt packages and convenience tooling on Debian/Ubuntu description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- base
- docker # If this a Container Enabled role, provide the minimum Ansible Container version.
- bootstrap # min_ansible_container_version:
- digitalboard
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] 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,154 +0,0 @@
# Ansible Role: bookstack
Deploys [BookStack](https://www.bookstackapp.com/) as a self-contained Docker
Compose stack behind Traefik, with its own MariaDB container, OIDC SSO
(Entra ID by default) and a daily systemd-timer driven backup of database
and uploads.
## Requirements
- Docker Engine + Compose plugin on the target host
- Traefik already running, with the external network referenced by
`bookstack_traefik_network` (default: `proxy`)
- `community.docker` collection on the controller
- DNS for `bookstack_domain` pointing at the Traefik host
## Required variables
The role asserts these are set; the play fails fast if any is empty:
| Variable | Description |
|---|---|
| `bookstack_db_root_password` | MariaDB root password |
| `bookstack_db_password` | MariaDB user password |
| `bookstack_admin_password` | Initial local admin password |
| `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.
## Optional variables
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)
- `bookstack_oidc_user_to_groups` (`true` syncs roles from Entra groups)
- `bookstack_backup_enabled`, `bookstack_backup_schedule`,
`bookstack_backup_retention_days`
## Entra ID app registration
1. Azure Portal → Entra ID → App registrations → New registration
2. Redirect URI (Web): `https://<bookstack_domain>/oidc/callback`
3. Front-channel logout URL: `https://<bookstack_domain>/logout`
4. Certificates & secrets → New client secret →
`bookstack_oidc_client_secret`
5. For group sync (`bookstack_oidc_user_to_groups: true`):
- Token configuration → Add groups claim → Security groups
- In BookStack, create roles whose **External Auth ID** equals the
Entra group Object ID, so the mapping resolves on first login.
## What the role does
| Phase | Action |
|---|---|
| Validate | `assert` all required secrets are set |
| Prepare | install packages, create volume dirs, generate persistent `APP_KEY`, verify Traefik network |
| Deploy | render `docker-compose.yml`, pull images, bring stack up |
| Configure | wait for the app, create the initial local admin via `php artisan bookstack:create-admin` (idempotent) |
| Backup | render `/usr/local/bin/bookstack-backup.sh` + systemd timer (daily 03:00, 14-day retention) |
## Example playbook
```yaml
- name: Deploy BookStack service
hosts: bookstack_servers
become: true
roles:
- digitalboard.core.bookstack
```
With inventory variables:
```yaml
# group_vars/bookstack_servers.yml
bookstack_domain: wiki.digitalboard.ch
bookstack_base_url: "https://wiki.digitalboard.ch"
bookstack_entra_tenant_id: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/bookstack',
mount_point='kv').data.data.tenant_id }}"
bookstack_oidc_client_id: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/bookstack',
mount_point='kv').data.data.client_id }}"
bookstack_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/bookstack',
mount_point='kv').data.data.client_secret }}"
bookstack_db_root_password: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/bookstack',
mount_point='kv').data.data.db_root_password }}"
bookstack_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/bookstack',
mount_point='kv').data.data.db_password }}"
bookstack_admin_password: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/bookstack',
mount_point='kv').data.data.admin_password }}"
```
## Backup / restore
Backups land in `{{ bookstack_backup_dir }}` (default
`/srv/data/bookstack/backup`) with three files per run:
- `bookstack-db-<stamp>.sql.gz` — mariadb-dump
- `bookstack-files-<stamp>.tar.gz` — uploads, attachments
- `bookstack-appkey-<stamp>.txt` — APP_KEY (required for restore!)
Manual trigger: `systemctl start bookstack-backup.service`
Timer status: `systemctl list-timers bookstack-backup.timer`
Restore procedure:
1. Stop the stack: `docker compose down` in `bookstack_docker_compose_dir`
2. Restore the APP_KEY: copy the `.txt` content to
`{{ bookstack_docker_volume_dir }}/.app_key` (the key MUST match or
encrypted DB values become unreadable)
3. Start only the DB container, then load the dump:
```bash
gunzip -c bookstack-db-<stamp>.sql.gz \
| docker exec -i bookstack-db \
mariadb -u root -p"<root-pw>" bookstack
```
4. Extract the files: `tar -xzf bookstack-files-<stamp>.tar.gz -C
{{ bookstack_appdata_dir }}/www/`
5. Bring the stack back up: `docker compose up -d`
## Notes
- `bookstack_oidc_auto_initiate: false` (default) shows a login page
with an SSO button alongside the local login form. With `true`, users
go straight to the IdP — the local admin then has to use
`https://<domain>/login?email_login=1`.
- `bookstack_oidc_user_to_groups: true` only makes sense once BookStack
roles with the correct **External Auth IDs** (= Entra group Object
IDs) exist; otherwise users lose their role assignment on every login.
- Image tags default to pinned versions; bump them deliberately rather
than chasing `latest`.
- BookStack officially supports MySQL/MariaDB only — no PostgreSQL.
## License
MIT-0

View file

@ -1,93 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# defaults file for bookstack
# Base directory configuration (inherited from base role or defined here)
docker_compose_base_dir: /etc/docker/compose
docker_volume_base_dir: /srv/data
# bookstack-specific configuration
bookstack_service_name: bookstack
bookstack_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ bookstack_service_name }}"
bookstack_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ bookstack_service_name }}"
bookstack_appdata_dir: "{{ bookstack_docker_volume_dir }}/appdata"
bookstack_db_data_dir: "{{ bookstack_docker_volume_dir }}/db"
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
bookstack_image: "lscr.io/linuxserver/bookstack:version-v26.03.3"
bookstack_db_image: "lscr.io/linuxserver/mariadb:11.4.9"
# Traefik configuration
bookstack_traefik_network: "proxy"
bookstack_traefik_certresolver: "le"
# Timezone / UID
bookstack_tz: "Europe/Zurich"
bookstack_puid: "1000"
bookstack_pgid: "1000"
# Database configuration
bookstack_db_name: "bookstack"
bookstack_db_user: "bookstack"
# REQUIRED SECRETS — empty defaults force `assert` to fail until set.
# Provide via OpenBao lookup, Ansible Vault, or extra-vars.
# Never commit real secrets to version control.
#
# Generate with:
# bookstack_db_root_password: openssl rand -base64 32 | tr -d '/+='
# bookstack_db_password: openssl rand -base64 32 | tr -d '/+='
# bookstack_admin_password: openssl rand -base64 24 | tr -d '/+='
bookstack_db_root_password: ""
bookstack_db_password: ""
bookstack_admin_password: ""
bookstack_oidc_client_secret: ""
# APP_KEY is generated automatically on first run and persisted on the host.
# Set explicitly only if restoring an existing instance.
bookstack_app_key: ""
# Initial local admin (fallback account, lives alongside OIDC)
bookstack_admin_name: "Admin"
bookstack_admin_email: "admin@local.test"
bookstack_artisan_path: "/app/www/artisan"
# Mail configuration
bookstack_mail_driver: "smtp"
bookstack_mail_host: "smtp.local.test"
bookstack_mail_port: 587
bookstack_mail_encryption: "tls"
bookstack_mail_from: "bookstack@local.test"
bookstack_mail_from_name: "BookStack"
bookstack_mail_username: ""
bookstack_mail_password: ""
# OIDC configuration (Entra ID by default; override `bookstack_oidc_issuer`
# for Keycloak or any other provider)
bookstack_oidc_enabled: false
bookstack_oidc_name: "SSO"
bookstack_entra_tenant_id: ""
bookstack_oidc_issuer: "https://login.microsoftonline.com/{{ bookstack_entra_tenant_id }}/v2.0"
bookstack_oidc_client_id: ""
bookstack_oidc_auto_initiate: false
bookstack_oidc_user_to_groups: false
bookstack_oidc_groups_claim: "groups"
bookstack_oidc_additional_scopes: "openid profile email"
# Backup configuration
bookstack_backup_enabled: true
bookstack_backup_retention_days: 14
bookstack_backup_schedule: "*-*-* 03:00:00"

View file

@ -1,19 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# handlers file for bookstack
- name: stop bookstack
community.docker.docker_compose_v2:
project_src: "{{ bookstack_docker_compose_dir }}"
state: stopped
listen: restart bookstack
- name: start bookstack
community.docker.docker_compose_v2:
project_src: "{{ bookstack_docker_compose_dir }}"
state: present
listen: restart bookstack
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true

View file

@ -1,212 +0,0 @@
---
argument_specs:
main:
short_description: Deploy BookStack (LSIO image + MariaDB) via Docker Compose.
description:
- Renders a Compose stack for the linuxserver.io BookStack image
with a sibling MariaDB container behind Traefik, then bootstraps
the initial admin user via C(php artisan bookstack:create-admin)
and optionally enables OIDC SSO (Entra ID by default).
- "Persists the Laravel C(APP_KEY) on the host so the same key is
re-used across deploys (a fresh key would orphan all encrypted
database values: 2FA secrets, API tokens, OIDC client_secret)."
- Ships an optional systemd timer that backs up the database dump,
uploads tarball and APP_KEY daily with configurable retention.
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
bookstack_service_name:
type: str
default: bookstack
bookstack_docker_compose_dir:
type: path
bookstack_docker_volume_dir:
type: path
bookstack_appdata_dir:
type: path
bookstack_db_data_dir:
type: path
bookstack_backup_dir:
type: path
bookstack_domain:
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 }}").
bookstack_image:
type: str
default: "lscr.io/linuxserver/bookstack:version-v26.03.3"
bookstack_db_image:
type: str
default: "lscr.io/linuxserver/mariadb:11.4.9"
bookstack_traefik_network:
type: str
default: proxy
bookstack_traefik_certresolver:
type: str
default: le
bookstack_tz:
type: str
default: Europe/Zurich
bookstack_puid:
type: str
default: "1000"
bookstack_pgid:
type: str
default: "1000"
bookstack_db_name:
type: str
default: bookstack
bookstack_db_user:
type: str
default: bookstack
bookstack_db_root_password:
type: str
required: true
description: MariaDB C(root) password. Override per-inventory.
bookstack_db_password:
type: str
required: true
description: MariaDB C(bookstack_db_user) password. Override per-inventory.
bookstack_admin_password:
type: str
required: true
description:
- Password for the local admin user that the role creates via
C(bookstack:create-admin). Lives alongside any OIDC users.
bookstack_app_key:
type: str
default: ''
description:
- When empty the role generates a persistent C(APP_KEY) on first
run and stores it under C({{ bookstack_docker_volume_dir }}/.app_key).
Override only when restoring an existing instance — a mismatching
key orphans all encrypted database values.
bookstack_admin_name:
type: str
default: Admin
bookstack_admin_email:
type: str
default: admin@local.test
bookstack_artisan_path:
type: path
default: /app/www/artisan
description:
- Path to BookStack's C(artisan) script inside the container. The
LSIO image's C(WORKDIR) is not the app directory, so this must
be absolute.
bookstack_mail_driver:
type: str
choices: [smtp, log, sendmail, mailgun, ses, postmark]
default: smtp
bookstack_mail_host:
type: str
default: smtp.local.test
bookstack_mail_port:
type: int
default: 587
bookstack_mail_encryption:
type: str
choices: [tls, ssl, '']
default: tls
bookstack_mail_from:
type: str
default: bookstack@local.test
bookstack_mail_from_name:
type: str
default: BookStack
bookstack_mail_username:
type: str
default: ''
bookstack_mail_password:
type: str
default: ''
bookstack_oidc_enabled:
type: bool
default: false
bookstack_oidc_name:
type: str
default: SSO
description: Display name of the SSO button on the login page.
bookstack_entra_tenant_id:
type: str
default: ''
description: Entra tenant UUID. Required when C(bookstack_oidc_enabled=true).
bookstack_oidc_issuer:
type: str
description:
- OIDC issuer URL. Defaults to the Entra v2 issuer template
built from C(bookstack_entra_tenant_id). Override for
Keycloak or any other provider.
bookstack_oidc_client_id:
type: str
default: ''
description: Required when C(bookstack_oidc_enabled=true).
bookstack_oidc_client_secret:
type: str
default: ''
description: Required when C(bookstack_oidc_enabled=true).
bookstack_oidc_auto_initiate:
type: bool
default: false
description:
- When true users are redirected straight to the IdP and the
local login is reachable only via C(?email_login=1).
bookstack_oidc_user_to_groups:
type: bool
default: false
description:
- When true BookStack syncs roles from the IdP groups claim
on every login. Requires BookStack roles whose
C(External Auth ID) matches the IdP group's Object ID.
bookstack_oidc_groups_claim:
type: str
default: groups
bookstack_oidc_additional_scopes:
type: str
default: openid profile email
bookstack_backup_enabled:
type: bool
default: true
bookstack_backup_retention_days:
type: int
default: 14
bookstack_backup_schedule:
type: str
default: "*-*-* 03:00:00"
description: systemd C(OnCalendar) expression for the backup timer.

View file

@ -1,25 +0,0 @@
galaxy_info:
author: digitalboard
description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik
company: digitalboard
license: MIT-0
min_ansible_version: "2.14"
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
galaxy_tags:
- docker
- bookstack
- wiki
- documentation
- digitalboard
dependencies: []

View file

@ -1,229 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# tasks file for bookstack
# =====================================================================
# 1. VALIDATE REQUIRED SECRETS
# =====================================================================
- name: Assert required secrets are set
ansible.builtin.assert:
that:
- bookstack_db_root_password | length > 0
- bookstack_db_password | length > 0
- 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)
# 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.
quiet: true
# =====================================================================
# 2. PREPARATION: Packages, directories, APP_KEY
# =====================================================================
- name: Ensure required packages are installed
ansible.builtin.package:
name:
- python3-docker
- python3-requests
state: present
- name: Create docker compose directory
ansible.builtin.file:
path: "{{ bookstack_docker_compose_dir }}"
state: directory
mode: '0755'
- name: Create BookStack data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ bookstack_puid }}"
group: "{{ bookstack_pgid }}"
mode: '0755'
loop:
- "{{ bookstack_docker_volume_dir }}"
- "{{ bookstack_appdata_dir }}"
- "{{ bookstack_db_data_dir }}"
- "{{ bookstack_backup_dir }}"
- name: Verify Traefik network exists
community.docker.docker_network_info:
name: "{{ bookstack_traefik_network }}"
register: _traefik_net
failed_when: not _traefik_net.exists
- name: Check whether APP_KEY has been generated before
ansible.builtin.stat:
path: "{{ bookstack_docker_volume_dir }}/.app_key"
register: _app_key_file
- name: Generate persistent APP_KEY on first run
ansible.builtin.shell: |
set -o pipefail
umask 077
echo "base64:$(openssl rand -base64 32)" > {{ bookstack_docker_volume_dir }}/.app_key
args:
executable: /bin/bash
creates: "{{ bookstack_docker_volume_dir }}/.app_key"
when:
- not _app_key_file.stat.exists
- bookstack_app_key | length == 0
- name: Write inventory-provided APP_KEY
ansible.builtin.copy:
content: "{{ bookstack_app_key }}\n"
dest: "{{ bookstack_docker_volume_dir }}/.app_key"
mode: '0600'
when:
- not _app_key_file.stat.exists
- bookstack_app_key | length > 0
no_log: true
- name: Read APP_KEY back into a fact
ansible.builtin.slurp:
src: "{{ bookstack_docker_volume_dir }}/.app_key"
register: _app_key_slurp
no_log: true
- name: Register APP_KEY fact
ansible.builtin.set_fact:
bookstack_resolved_app_key: "{{ _app_key_slurp.content | b64decode | trim }}"
no_log: true
# =====================================================================
# 3. DEPLOY: Render compose, bring stack up
# =====================================================================
- name: Render docker-compose.yml for BookStack
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ bookstack_docker_compose_dir }}/docker-compose.yml"
mode: '0640'
notify: restart bookstack
- name: Start BookStack containers
community.docker.docker_compose_v2:
project_src: "{{ bookstack_docker_compose_dir }}"
state: present
pull: always
wait: true
# =====================================================================
# 4. CONFIGURE: Wait for app and seed initial admin user
# =====================================================================
- name: Wait for BookStack to be ready
ansible.builtin.command:
cmd: docker exec {{ bookstack_service_name }} curl -sf -o /dev/null -w "%{http_code}" http://localhost/login
register: _bookstack_health
retries: 30
delay: 5
until: _bookstack_health.stdout == "200"
changed_when: false
- name: Wait for BookStack migrations to be complete
community.docker.docker_container_exec:
container: "{{ bookstack_service_name }}-db"
argv:
- mariadb
- --protocol=tcp
- -h
- 127.0.0.1
- -u
- "{{ bookstack_db_user }}"
- "-p{{ bookstack_db_password }}"
- "{{ bookstack_db_name }}"
- -Nse
- "SHOW TABLES LIKE 'users';"
register: _users_table
retries: 30
delay: 5
until: _users_table.stdout | trim == 'users'
changed_when: false
no_log: true
- name: Check whether the initial admin already exists
community.docker.docker_container_exec:
container: "{{ bookstack_service_name }}-db"
argv:
- mariadb
- --protocol=tcp
- -h
- 127.0.0.1
- -u
- "{{ bookstack_db_user }}"
- "-p{{ bookstack_db_password }}"
- "{{ bookstack_db_name }}"
- -Nse
- "SELECT COUNT(*) FROM users WHERE email = '{{ bookstack_admin_email }}';"
register: _admin_exists
changed_when: false
no_log: true
- name: Create initial admin user
community.docker.docker_container_exec:
container: "{{ bookstack_service_name }}"
argv:
- php
- "{{ bookstack_artisan_path }}"
- bookstack:create-admin
- "--email={{ bookstack_admin_email }}"
- "--name={{ bookstack_admin_name }}"
- "--password={{ bookstack_admin_password }}"
when: (_admin_exists.stdout | trim | int) == 0
no_log: true
# =====================================================================
# 5. BACKUP: systemd timer for daily DB + uploads dump
# =====================================================================
- name: Render backup script
ansible.builtin.template:
src: backup.sh.j2
dest: /usr/local/bin/bookstack-backup.sh
owner: root
group: root
mode: '0750'
when: bookstack_backup_enabled | bool
- name: Render backup systemd service
ansible.builtin.template:
src: bookstack-backup.service.j2
dest: /etc/systemd/system/bookstack-backup.service
mode: '0644'
when: bookstack_backup_enabled | bool
notify: reload systemd
- name: Render backup systemd timer
ansible.builtin.template:
src: bookstack-backup.timer.j2
dest: /etc/systemd/system/bookstack-backup.timer
mode: '0644'
when: bookstack_backup_enabled | bool
notify: reload systemd
- name: Enable and start backup timer
ansible.builtin.systemd:
name: bookstack-backup.timer
enabled: true
state: started
daemon_reload: true
when: bookstack_backup_enabled | bool
- name: Disable backup timer when feature is off
ansible.builtin.systemd:
name: bookstack-backup.timer
enabled: false
state: stopped
when: not (bookstack_backup_enabled | bool)
failed_when: false

View file

@ -1,41 +0,0 @@
#!/bin/bash
# {{ ansible_managed }}
set -euo pipefail
BACKUP_DIR="{{ bookstack_backup_dir }}"
RETENTION_DAYS={{ bookstack_backup_retention_days }}
APPDATA_DIR="{{ bookstack_appdata_dir }}"
STAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
# --- DB dump (mariadb-dump from inside the DB container) ---
# Use the app user via TCP because root@localhost is unix_socket-auth only
# in the LSIO MariaDB image and root@% does not exist.
docker exec {{ bookstack_service_name }}-db \
mariadb-dump \
--protocol=tcp -h 127.0.0.1 \
-u "{{ bookstack_db_user }}" -p"{{ bookstack_db_password }}" \
--single-transaction --routines --triggers --quick \
"{{ bookstack_db_name }}" \
| gzip -9 > "$BACKUP_DIR/bookstack-db-$STAMP.sql.gz"
# --- File uploads (images, attachments) ---
# LSIO BookStack stores user uploads under /config/www/{uploads,storage/uploads,files}.
tar --warning=no-file-changed \
-czf "$BACKUP_DIR/bookstack-files-$STAMP.tar.gz" \
-C "$APPDATA_DIR/www" \
uploads storage/uploads files 2>/dev/null || true
# --- APP_KEY backup (critical for restore!) ---
install -m 0600 "{{ bookstack_docker_volume_dir }}/.app_key" \
"$BACKUP_DIR/bookstack-appkey-$STAMP.txt"
# --- Retention ---
find "$BACKUP_DIR" -type f \
\( -name 'bookstack-db-*.sql.gz' \
-o -name 'bookstack-files-*.tar.gz' \
-o -name 'bookstack-appkey-*.txt' \) \
-mtime +"$RETENTION_DAYS" -delete
echo "Backup complete: $STAMP"

View file

@ -1,12 +0,0 @@
# {{ ansible_managed }}
[Unit]
Description=BookStack backup (DB + uploads)
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/bookstack-backup.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

View file

@ -1,11 +0,0 @@
# {{ ansible_managed }}
[Unit]
Description=Daily BookStack backup
[Timer]
OnCalendar={{ bookstack_backup_schedule }}
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target

View file

@ -1,93 +0,0 @@
#---------------------------------------------------------------------#
# BookStack - Self-hosted wiki / knowledge base. #
#---------------------------------------------------------------------#
---
services:
{{ bookstack_service_name }}:
image: {{ bookstack_image }}
container_name: {{ bookstack_service_name }}
restart: unless-stopped
environment:
PUID: "{{ bookstack_puid }}"
PGID: "{{ bookstack_pgid }}"
TZ: "{{ bookstack_tz }}"
APP_URL: "{{ bookstack_base_url }}"
APP_KEY: "{{ bookstack_resolved_app_key }}"
DB_HOST: "{{ bookstack_service_name }}-db"
DB_PORT: "3306"
DB_DATABASE: "{{ bookstack_db_name }}"
DB_USERNAME: "{{ bookstack_db_user }}"
DB_PASSWORD: "{{ bookstack_db_password }}"
MAIL_DRIVER: "{{ bookstack_mail_driver }}"
MAIL_HOST: "{{ bookstack_mail_host }}"
MAIL_PORT: "{{ bookstack_mail_port }}"
MAIL_USERNAME: "{{ bookstack_mail_username }}"
MAIL_PASSWORD: "{{ bookstack_mail_password }}"
MAIL_ENCRYPTION: "{{ bookstack_mail_encryption }}"
MAIL_FROM: "{{ bookstack_mail_from }}"
MAIL_FROM_NAME: "{{ bookstack_mail_from_name }}"
{% if bookstack_oidc_enabled %}
AUTH_METHOD: "oidc"
AUTH_AUTO_INITIATE: "{{ bookstack_oidc_auto_initiate | string | lower }}"
OIDC_NAME: "{{ bookstack_oidc_name }}"
OIDC_DISPLAY_NAME_CLAIMS: "name"
OIDC_CLIENT_ID: "{{ bookstack_oidc_client_id }}"
OIDC_CLIENT_SECRET: "{{ bookstack_oidc_client_secret }}"
OIDC_ISSUER: "{{ bookstack_oidc_issuer }}"
OIDC_ISSUER_DISCOVER: "true"
OIDC_END_SESSION_ENDPOINT: "true"
OIDC_ADDITIONAL_SCOPES: "{{ bookstack_oidc_additional_scopes }}"
OIDC_USER_TO_GROUPS: "{{ bookstack_oidc_user_to_groups | string | lower }}"
OIDC_GROUPS_CLAIM: "{{ bookstack_oidc_groups_claim }}"
{% endif %}
volumes:
- {{ bookstack_appdata_dir }}:/config
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={% 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 }}"
- "traefik.http.services.{{ bookstack_service_name }}.loadbalancer.server.port=80"
{{ bookstack_service_name }}-db:
image: {{ bookstack_db_image }}
container_name: {{ bookstack_service_name }}-db
restart: unless-stopped
environment:
PUID: "{{ bookstack_puid }}"
PGID: "{{ bookstack_pgid }}"
TZ: "{{ bookstack_tz }}"
MYSQL_ROOT_PASSWORD: "{{ bookstack_db_root_password }}"
MYSQL_DATABASE: "{{ bookstack_db_name }}"
MYSQL_USER: "{{ bookstack_db_user }}"
MYSQL_PASSWORD: "{{ bookstack_db_password }}"
volumes:
- {{ bookstack_db_data_dir }}:/config
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u root --password=\"$$MYSQL_ROOT_PASSWORD\" --silent"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
networks:
{{ bookstack_traefik_network }}:
external: true
internal:
driver: bridge

View file

@ -1 +0,0 @@
localhost

View file

@ -1,5 +0,0 @@
---
- hosts: localhost
remote_user: root
roles:
- bookstack

View file

@ -1,42 +1,38 @@
# collabora Role Name
=========
Deploys [Collabora Online](https://www.collaboraonline.com/) (CODE, A brief description of the role goes here.
`collabora/code`) via Docker Compose behind Traefik. Collabora is the
WOPI backend that renders office documents for Nextcloud and OpenCloud.
The role templates `coolwsd.xml` to declare which WOPI hosts may call Requirements
Collabora and which origins may embed it in an iframe. ------------
## 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 | Role Variables
| --- | --- | --- | --------------
| `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. |
## 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 Dependencies
- hosts: services ------------
become: true
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: roles:
- role: digitalboard.core.collabora - { role: username.rolename, x: 42 }
vars:
collabora_domains:
- "office.example.com"
collabora_allowed_domains:
- "cloud.example.com"
collabora_frame_ancestors:
- "cloud.example.com"
```
## License License
-------
MIT-0 BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -12,11 +12,7 @@ collabora_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ collabora_servic
collabora_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ collabora_service_name }}" collabora_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ collabora_service_name }}"
# Service configuration # Service configuration
# FQDNs the collabora router accepts. The first entry is the canonical collabora_domain: "office.local.test"
# 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_image: "collabora/code:latest"
collabora_port: 9980 collabora_port: 9980
collabora_extra_hosts: [] collabora_extra_hosts: []

View file

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

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy Collabora Online (CODE) as a WOPI backend via Docker Compose behind Traefik description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- collabora
- office # If this a Container Enabled role, provide the minimum Ansible Container version.
- wopi # min_ansible_container_version:
- nextcloud
- docker galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] 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,14 +20,11 @@ services:
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network={{ collabora_traefik_network }} - traefik.docker.network={{ collabora_traefik_network }}
- traefik.http.routers.{{ collabora_service_name }}.rule={% for d in collabora_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} - traefik.http.routers.{{ collabora_service_name }}.rule=Host(`{{ collabora_domain }}`)
- traefik.http.services.{{ collabora_service_name }}.loadbalancer.server.port={{ collabora_port }} - traefik.http.services.{{ collabora_service_name }}.loadbalancer.server.port={{ collabora_port }}
{% if collabora_use_ssl %} {% if collabora_use_ssl %}
- traefik.http.routers.{{ collabora_service_name }}.entrypoints=websecure - traefik.http.routers.{{ collabora_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ collabora_service_name }}.tls=true - 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 %} {% else %}
- traefik.http.routers.{{ collabora_service_name }}.entrypoints=web - traefik.http.routers.{{ collabora_service_name }}.entrypoints=web
{% endif %} {% endif %}

View file

@ -1,69 +0,0 @@
# coturn
Deploys a [coturn](https://github.com/coturn/coturn) TURN/STUN server with `network_mode: host`,
optionally accompanied by an `acme.sh` sidecar that obtains and renews a public TLS certificate
via RFC2136 (`nsupdate`) and restarts coturn on renewal.
This is the recommended pairing for `digitalboard.core.talk` (Nextcloud Talk HPB).
## What it does
- Renders `/etc/docker/compose/coturn/docker-compose.yml`
- (acme mode) Deploys the TSIG key from `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key`
- (selfsigned mode) Generates an ECC keypair + selfsigned cert in `{{ coturn_cert_dir }}`
- Starts the stack via `community.docker.docker_compose_v2`
## Required variables
| Variable | Description |
|---|---|
| `coturn_realm` | Public DNS name used as realm + cert CN (e.g. `stun.digitalboard.ch`) |
| `coturn_external_ip` | Mapping for `--external-ip`, format `PUBLIC[/PRIVATE]` |
| `coturn_static_auth_secret` | Shared secret for HMAC-based credentials; **must match** `talk_turn_secret` on the HPB host |
## Important variables
| Variable | Default | Description |
|---|---|---|
| `coturn_cert_mode` | `file` | One of `acme`, `file`, `selfsigned` |
| `coturn_listening_port` | `443` | TCP/UDP non-TLS port |
| `coturn_tls_listening_port` | `443` | TLS port (shared with non-TLS via STUN mux) |
| `coturn_min_relay_port` / `coturn_max_relay_port` | `49160` / `49200` | UDP relay range |
| `coturn_internal_realm` | `""` | Optional second SAN for split-horizon DNS |
| `coturn_image` | `coturn/coturn:4.6.2-r5-alpine` | Pinned by default; override as needed |
## ACME / nsupdate mode
When `coturn_cert_mode: acme` is set, also configure:
```yaml
coturn_acme_email: "admin@digitalboard.ch"
coturn_acme_nsupdate_server: "ns1.digitalboard.ch"
coturn_acme_nsupdate_server_ip: "172.16.9.169" # optional pin
coturn_acme_nsupdate_zone: "digitalboard._acme.digitalboard.ch"
# optional: override the auto-built challenge alias mapping
coturn_acme_challenge_aliases:
- name: stun.digitalboard.ch
alias: stun.digitalboard._acme.digitalboard.ch
- name: stun.int.digitalboard.ch
alias: stun.int.digitalboard._acme.digitalboard.ch
```
Place your TSIG key at `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key` (mode 0600).
## Secrets
Place the static auth secret at:
```
playbooks/secrets/{{ inventory_hostname }}/coturn_static_auth_secret
```
Mode 0600. The same value must be deployed to the HPB host as `talk_turn_secret`.
## Firewall
The role does not manage firewall rules. Ensure the host has:
- `443/tcp` and `443/udp` reachable from the internet
- UDP `{{ coturn_min_relay_port }}-{{ coturn_max_relay_port }}` reachable from the internet

View file

@ -1,77 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# defaults file for coturn
# Base directories (inherited from base role)
docker_compose_base_dir: /etc/docker/compose
docker_volume_base_dir: /srv/data
# Service-specific paths
coturn_service_name: coturn
coturn_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ coturn_service_name }}"
coturn_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ coturn_service_name }}"
# Container images (pin per host_vars in production)
coturn_image: "coturn/coturn:4.6.2-r5-alpine"
coturn_acme_image: "neilpang/acme.sh:3.1.0"
# Public DNS name used for the realm and the public certificate
coturn_realm: "stun.example.test"
# Optional second DNS name issued on the same certificate (for split-horizon "internal" name)
coturn_internal_realm: "" # e.g. "stun.int.example.test"
# Ports
# Defaults follow IANA standards (3478/TURN, 5349/TURNS) so coturn can
# co-exist with a Traefik instance on the same host. Override to 443/443
# in restrictive-network environments where punching through firewalls matters.
coturn_listening_port: 3478 # TURN / STUN (TCP+UDP)
coturn_tls_listening_port: 5349 # TURNS (TCP+UDP)
coturn_min_relay_port: 49160
coturn_max_relay_port: 49200
# IP advertisement: must be set in host_vars for production
# Format follows coturn's --external-ip: "PUBLIC_IP" or "PUBLIC_IP/PRIVATE_IP"
coturn_external_ip: "" # e.g. "203.0.113.10/172.18.0.2"
coturn_listening_ip: "0.0.0.0"
# Shared secret used by HPB to mint short-lived TURN credentials.
# Loaded by default from a plain file in playbooks/secrets/{host}/coturn_static_auth_secret
# Override per host_vars if you want to use a vault or different lookup.
coturn_static_auth_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/coturn_static_auth_secret') }}"
# Additional CLI flags (list of strings, appended verbatim to command:)
coturn_extra_args: []
# --- TLS certificate ---
# 'acme' : run an acme.sh sidecar that issues + renews via RFC2136 / nsupdate, restarts coturn
# 'file' : assume a certificate already lives at {{ coturn_cert_dir }}/{{ coturn_cert_file }} on the host (you manage it)
# 'selfsigned' : generate a selfsigned cert on first run (for vagrant/dev only)
coturn_cert_mode: "file"
coturn_cert_dir: "{{ docker_volume_base_dir }}/acme/certs"
coturn_cert_file: "fullchain.cer"
coturn_key_file: "{{ coturn_realm }}.key"
# --- acme.sh sidecar (only used when coturn_cert_mode == 'acme') ---
coturn_acme_email: "admin@example.test"
coturn_acme_directory: "https://acme-v02.api.letsencrypt.org/directory"
# Stage URL for testing: "https://acme-staging-v02.api.letsencrypt.org/directory"
coturn_acme_keylength: "ec-256"
coturn_acme_dnssleep: 60
coturn_acme_data_dir: "{{ docker_volume_base_dir }}/acme/acme"
# DNS-01 RFC2136 / nsupdate configuration
coturn_acme_nsupdate_server: "" # e.g. "ns1.example.test"
coturn_acme_nsupdate_server_ip: "" # optional extra_hosts pin (string IP) for the server
coturn_acme_nsupdate_zone: "" # e.g. "example._acme.example.test"
# Per-name challenge alias zones (one entry per SAN)
# When empty (default), built automatically as "{{ realm }}._acme.{{ zone-tail }}"
coturn_acme_challenge_aliases: []
# Example:
# - name: stun.example.test
# alias: stun.example._acme.example.test
# - name: stun.int.example.test
# alias: stun.int.example._acme.example.test
# Path of the TSIG key file inside the container (mounted from secrets)
coturn_acme_nsupdate_key_src: "{{ playbook_dir }}/secrets/{{ inventory_hostname }}/nsupdate.key"

View file

@ -1,10 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# handlers file for coturn
- name: Restart coturn container
community.docker.docker_compose_v2:
project_src: "{{ coturn_docker_compose_dir }}"
state: restarted
services:
- coturn

View file

@ -1,148 +0,0 @@
---
argument_specs:
main:
short_description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar.
description:
- "Renders a Docker Compose stack for coturn running in
C(network_mode: host), with an optional C(acme.sh) sidecar that
issues + renews a public TLS certificate via RFC2136 / nsupdate
and restarts coturn on renewal."
- Designed to be paired with the C(digitalboard.core.talk) role
(Nextcloud Talk High Performance Backend).
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
coturn_service_name:
type: str
default: coturn
coturn_docker_compose_dir:
type: path
coturn_docker_volume_dir:
type: path
coturn_image:
type: str
default: "coturn/coturn:4.6.2-r5-alpine"
coturn_acme_image:
type: str
default: "neilpang/acme.sh:3.1.0"
coturn_realm:
type: str
default: stun.example.test
description: Public DNS name used for the TURN realm and the public certificate.
coturn_internal_realm:
type: str
default: ''
description:
- Optional second DNS name issued on the same certificate, used for
split-horizon internal access (e.g. C(stun.int.example.test)).
coturn_listening_port:
type: int
default: 3478
description: TURN/STUN port (TCP + UDP). IANA standard is 3478.
coturn_tls_listening_port:
type: int
default: 5349
description: TURNS port (TCP + UDP). IANA standard is 5349.
coturn_min_relay_port:
type: int
default: 49160
coturn_max_relay_port:
type: int
default: 49200
coturn_external_ip:
type: str
default: ''
description:
- coturn C(--external-ip) value. Format C("PUBLIC_IP") or
C("PUBLIC_IP/PRIVATE_IP"). Must be set in host_vars for production.
coturn_listening_ip:
type: str
default: '0.0.0.0'
coturn_static_auth_secret:
type: str
required: true
description:
- Shared secret used by the HPB signaling server to mint short-lived
TURN credentials. Default lookup reads
C(playbooks/secrets/<host>/coturn_static_auth_secret).
coturn_extra_args:
type: list
elements: str
default: []
description: Additional CLI flags appended verbatim to the container C(command:).
coturn_cert_mode:
type: str
choices: [acme, file, selfsigned]
default: file
description:
- C(acme) runs an acme.sh sidecar that issues + renews via RFC2136
and restarts coturn. C(file) assumes a certificate already lives
on the host (you manage it). C(selfsigned) generates one on first
run (vagrant/dev only).
coturn_cert_dir:
type: path
coturn_cert_file:
type: str
default: fullchain.cer
coturn_key_file:
type: str
description: Defaults to C("{{ coturn_realm }}.key").
coturn_acme_email:
type: str
default: admin@example.test
coturn_acme_directory:
type: str
default: https://acme-v02.api.letsencrypt.org/directory
coturn_acme_keylength:
type: str
default: ec-256
choices: [ec-256, ec-384, '2048', '3072', '4096']
coturn_acme_dnssleep:
type: int
default: 60
coturn_acme_data_dir:
type: path
coturn_acme_nsupdate_server:
type: str
default: ''
description: Authoritative nameserver acme.sh sends C(nsupdate) packets to.
coturn_acme_nsupdate_server_ip:
type: str
default: ''
description: Optional C(extra_hosts) pin (string IP) for the nsupdate server.
coturn_acme_nsupdate_zone:
type: str
default: ''
description: Delegated challenge zone (e.g. C(example._acme.example.test)).
coturn_acme_challenge_aliases:
type: list
elements: dict
default: []
description:
- Per-name challenge alias zones (one entry per SAN). When empty,
built automatically as C({{ realm }}._acme.{{ zone-tail }}).
options:
name:
type: str
required: true
description: SAN the challenge is for.
alias:
type: str
required: true
description: CNAME target where the C(_acme-challenge) TXT lives.
coturn_acme_nsupdate_key_src:
type: path
description: Path of the TSIG key file on the controller, mounted into the acme container.

View file

@ -1,15 +0,0 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: Digital Board Team
description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar (RFC2136/nsupdate)
company: digitalboard.ch
license: GPL-2.0-or-later
min_ansible_version: "2.14"
galaxy_tags:
- turn
- stun
- coturn
- webrtc
- nextcloud
- talk
dependencies: []

View file

@ -1,110 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# tasks file for coturn
- name: Assert minimum configuration
ansible.builtin.assert:
that:
- coturn_realm | length > 0
- coturn_external_ip | length > 0
- coturn_static_auth_secret | length > 0
fail_msg: >
coturn_realm, coturn_external_ip and coturn_static_auth_secret must be set.
Provide them in host_vars or via a secrets file.
- name: Create coturn compose directory
ansible.builtin.file:
path: "{{ coturn_docker_compose_dir }}"
state: directory
mode: "0755"
- name: Create coturn data directory
ansible.builtin.file:
path: "{{ coturn_docker_volume_dir }}"
state: directory
mode: "0755"
- name: Create certificate directory
ansible.builtin.file:
path: "{{ coturn_cert_dir }}"
state: directory
mode: "0755"
# --- TLS certificate provisioning -------------------------------------------------
- name: Configure acme.sh sidecar (TSIG key + acme data dir)
when: coturn_cert_mode == 'acme'
block:
- name: Create acme.sh data directory
ansible.builtin.file:
path: "{{ coturn_acme_data_dir }}"
state: directory
mode: "0700"
- name: Deploy nsupdate TSIG key
ansible.builtin.copy:
src: "{{ coturn_acme_nsupdate_key_src }}"
dest: "{{ coturn_docker_compose_dir }}/nsupdate.key"
mode: "0600"
no_log: true
notify: Restart coturn container
- name: Build effective challenge alias list (default if not provided)
ansible.builtin.set_fact:
_coturn_challenge_aliases: >-
{{ coturn_acme_challenge_aliases
if coturn_acme_challenge_aliases | length > 0
else (
[{'name': coturn_realm,
'alias': (coturn_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }]
+ ([{'name': coturn_internal_realm,
'alias': (coturn_internal_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }]
if coturn_internal_realm | length > 0 else [])
)
}}
- name: Generate selfsigned certificate (vagrant / dev only)
when: coturn_cert_mode == 'selfsigned'
block:
- name: Ensure openssl is available
ansible.builtin.package:
name: openssl
state: present
- name: Generate selfsigned private key
community.crypto.openssl_privatekey:
path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}"
type: ECC
curve: secp256r1
mode: "0600"
- name: Generate selfsigned CSR
community.crypto.openssl_csr:
path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr"
privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}"
common_name: "{{ coturn_realm }}"
subject_alt_name:
- "DNS:{{ coturn_realm }}"
mode: "0644"
- name: Issue selfsigned certificate
community.crypto.x509_certificate:
path: "{{ coturn_cert_dir }}/{{ coturn_cert_file }}"
privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}"
csr_path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr"
provider: selfsigned
mode: "0644"
# --- Compose + start --------------------------------------------------------------
- name: Generate docker-compose.yml for coturn
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ coturn_docker_compose_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart coturn container
- name: Start coturn stack
community.docker.docker_compose_v2:
project_src: "{{ coturn_docker_compose_dir }}"
state: present

View file

@ -1,78 +0,0 @@
services:
coturn:
image: {{ coturn_image }}
container_name: {{ coturn_service_name }}
restart: always
network_mode: host
volumes:
- {{ coturn_cert_dir }}:/certs:ro
command:
- --use-auth-secret
- --static-auth-secret={{ coturn_static_auth_secret }}
- --realm={{ coturn_realm }}
- --fingerprint
- --no-multicast-peers
- --no-cli
- --listening-ip={{ coturn_listening_ip }}
- --listening-port={{ coturn_listening_port }}
- --tls-listening-port={{ coturn_tls_listening_port }}
- --min-port={{ coturn_min_relay_port }}
- --max-port={{ coturn_max_relay_port }}
- --cert=/certs/{{ coturn_cert_file }}
- --pkey=/certs/{{ coturn_key_file }}
- --external-ip={{ coturn_external_ip }}
{% for arg in coturn_extra_args %}
- {{ arg }}
{% endfor %}
{% if coturn_cert_mode == 'acme' %}
acme:
image: {{ coturn_acme_image }}
container_name: acme-{{ coturn_service_name }}
restart: always
environment:
NSUPDATE_SERVER: "{{ coturn_acme_nsupdate_server }}"
NSUPDATE_KEY: "/acme.sh/nsupdate.key"
ACME_DIRECTORY: "{{ coturn_acme_directory }}"
NSUPDATE_ZONE: "{{ coturn_acme_nsupdate_zone }}"
{% if coturn_acme_nsupdate_server_ip | length > 0 %}
extra_hosts:
- "{{ coturn_acme_nsupdate_server }}:{{ coturn_acme_nsupdate_server_ip }}"
{% endif %}
volumes:
- {{ coturn_cert_dir }}:/certs
- /var/run/docker.sock:/var/run/docker.sock
- {{ coturn_docker_compose_dir }}/nsupdate.key:/acme.sh/nsupdate.key:ro
- {{ coturn_acme_data_dir }}:/acme.sh
entrypoint:
- /bin/sh
- -c
- |
set -eu
acme.sh --set-default-ca --server "$$ACME_DIRECTORY"
acme.sh --register-account -m {{ coturn_acme_email }} || true
set +e
acme.sh --issue \
{% for san in _coturn_challenge_aliases %}
-d {{ san.name }} \
--challenge-alias {{ san.alias }} \
{% endfor %}
--dns dns_nsupdate \
--keylength {{ coturn_acme_keylength }} \
--dnssleep {{ coturn_acme_dnssleep }}
rc=$$?
set -e
if [ "$$rc" -eq 0 ]; then
echo "Issue: success"
elif [ "$$rc" -eq 2 ]; then
echo "Issue: not due, continuing"
else
echo "Issue: failed with rc=$$rc"
exit "$$rc"
fi
acme.sh --install-cert -d {{ coturn_realm }} --ecc \
--fullchain-file /certs/{{ coturn_cert_file }} \
--key-file /certs/{{ coturn_key_file }} \
--reloadcmd 'curl --fail --silent --show-error --unix-socket /var/run/docker.sock -X POST http://localhost/v1.41/containers/{{ coturn_service_name }}/restart' || true
exec crond -f
{% endif %}

View file

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

View file

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

View file

@ -1,60 +1,38 @@
# Drawio Role Name
=========
Ansible role to deploy [draw.io](https://www.drawio.com/) (the A brief description of the role goes here.
self-hosted `jgraph/drawio` container) via Docker Compose behind
Traefik, with optional authentik ForwardAuth gating.
## Requirements Requirements
------------
- Docker and Docker Compose installed on the target host 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.
- 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
--------------
Full spec with types and defaults: `meta/argument_specs.yml`. The most 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.
common overrides:
### Service Dependencies
------------
- `drawio_domain`: canonical hostname used in the traefik Host rule 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.
(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`.
### Authentik ForwardAuth Example Playbook
----------------
- `drawio_authentik_forward_auth`: set to `true` to gate the editor Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
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`.
When enabled, traefik redirects unauthenticated requests to authentik - hosts: servers
for login and forwards the resulting `X-Authentik-*` identity headers
downstream.
## Dependencies
- Traefik network (`drawio_traefik_network`, default `proxy`)
- Optional: authentik with a Proxy/ForwardAuth provider for drawio
(see the `authentik` role's `authentik_proxy_apps`).
## Example playbook
```yaml
- hosts: app_servers
roles: roles:
- role: digitalboard.core.drawio - { role: username.rolename, x: 42 }
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 License
-------
MIT-0 BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -11,10 +11,6 @@ drawio_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ drawio_service_name
# Service configuration # Service configuration
drawio_domain: "drawio.local.test" 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_image: "jgraph/drawio:latest"
drawio_port: 8080 drawio_port: 8080
drawio_extra_hosts: [] drawio_extra_hosts: []
@ -22,10 +18,3 @@ drawio_extra_hosts: []
# Traefik configuration # Traefik configuration
drawio_traefik_network: "proxy" 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 - name: restart drawio
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ drawio_docker_compose_dir }}" project_src: "{{ drawio_docker_compose_dir }}"
state: present state: restarted

View file

@ -1,64 +0,0 @@
---
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,26 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy the draw.io diagram editor via Docker Compose behind Traefik, with optional authentik ForwardAuth description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- drawio
- diagrams # If this a Container Enabled role, provide the minimum Ansible Container version.
- docker # min_ansible_container_version:
- traefik
- digitalboard galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] 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,26 +14,14 @@ services:
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network={{ drawio_traefik_network }} - traefik.docker.network={{ drawio_traefik_network }}
- 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.routers.{{ drawio_service_name }}.rule=Host(`{{ drawio_domain }}`)
- traefik.http.services.{{ drawio_service_name }}.loadbalancer.server.port={{ drawio_port }} - traefik.http.services.{{ drawio_service_name }}.loadbalancer.server.port={{ drawio_port }}
{% if drawio_use_ssl %} {% if drawio_use_ssl %}
- traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure - traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ drawio_service_name }}.tls=true - 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 %} {% else %}
- traefik.http.routers.{{ drawio_service_name }}.entrypoints=web - traefik.http.routers.{{ drawio_service_name }}.entrypoints=web
{% endif %} {% 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: networks:
{{ drawio_traefik_network }}: {{ drawio_traefik_network }}:

View file

@ -1,118 +1,113 @@
# Garage Garage
======
Ansible role to deploy [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible Ansible role to deploy Garage S3-compatible object storage using Docker Compose.
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 - Docker and Docker Compose installed on the target host
- Ansible collection: `community.docker` - Ansible collection: `community.docker`
- `htpasswd` (from `apache2-utils` / `httpd-tools`) when the WebUI is - Traefik reverse proxy (for external access)
enabled and authentik ForwardAuth is *not* used
- Traefik with a shared `garage_traefik_network` (default `proxy`)
## Role variables Role Variables
--------------
Full spec with types and defaults: `meta/argument_specs.yml`. The most Key variables defined in `defaults/main.yml`:
common overrides:
### Service **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`)
- `garage_s3_domains`: FQDNs the S3 router accepts. The first entry is the **Garage Configuration:**
canonical hostname; `garage.toml` derives the virtual-hosted-style S3 - `garage_service_name`: Service name (default: `garage`)
`root_domain` from it as `.s3.<first-entry>` (so buckets resolve under - `garage_image`: Garage Docker image (default: `dxflrs/garage:v2.1.0`)
`<bucket>.s3.<first-entry>`). - `garage_s3_domain`: Domain for S3 API endpoint (default: `storage.local.test`)
- `garage_web_domain`, `garage_webui_domain`: separate hostnames for - `garage_web_domain`: Domain for S3 web endpoint (default: `web.storage.local.test`)
the S3-website endpoint and the console. - `garage_webui_domain`: Domain for web console (default: `console.storage.local.test`)
- `garage_image`, `garage_replication_factor`, `garage_db_engine`,
`garage_s3_region`.
### Required secrets **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`)
Generate with `openssl rand -hex 32` (32 bytes / 64 hex chars): **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`)
- `garage_rpc_secret`: node-to-node RPC secret **Garage Security:**
- `garage_admin_token`: admin API token - `garage_rpc_secret`: RPC secret for node communication
- `garage_metrics_token`: metrics endpoint token - `garage_admin_token`: Admin API token
- `garage_metrics_token`: Metrics API token
### WebUI authentication **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`)
Three modes: **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`)
1. **htpasswd** (default): `garage_webui_username` / `garage_webui_password` Dependencies
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`.
### Layout bootstrap 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
Setting `garage_bootstrap_enabled: true` runs the bootstrap task, which Example Playbook
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 ```yaml
- hosts: storage_servers - hosts: storage_servers
roles: roles:
- role: digitalboard.core.garage - role: garage
vars: vars:
garage_s3_domains: garage_s3_domain: "storage.example.com"
- "storage.example.com" garage_rpc_secret: "your-secure-rpc-secret"
- "storage.int.example.com" garage_admin_token: "your-admin-token"
garage_rpc_secret: "{{ vault_garage_rpc_secret }}" garage_webui_enabled: true
garage_admin_token: "{{ vault_garage_admin_token }}" garage_webui_username: "admin"
garage_metrics_token: "{{ vault_garage_metrics_token }}" garage_webui_password: "secure-password"
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]
``` ```
## License **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.
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,12 +13,7 @@ garage_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ garage_service_name }
# Garage service configuration # Garage service configuration
garage_image: "dxflrs/garage:v2.1.0" garage_image: "dxflrs/garage:v2.1.0"
# FQDNs the garage S3 router accepts. The first entry is the canonical garage_s3_domain: "storage.local.test"
# 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_web_domain: "web.storage.local.test"
garage_webui_domain: "console.storage.local.test" garage_webui_domain: "console.storage.local.test"
@ -26,20 +21,10 @@ garage_webui_domain: "console.storage.local.test"
garage_webui_enabled: true garage_webui_enabled: true
garage_webui_image: "khairul169/garage-webui:latest" garage_webui_image: "khairul169/garage-webui:latest"
garage_webui_port: 3909 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_username: "admin"
garage_webui_password: "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 ports
garage_s3_api_port: 3900 garage_s3_api_port: 3900
garage_s3_web_port: 3902 garage_s3_web_port: 3902

View file

@ -1,169 +0,0 @@
---
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,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy Garage S3-compatible object storage via Docker Compose, with declarative key/bucket provisioning description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- garage
- s3 # If this a Container Enabled role, provide the minimum Ansible Container version.
- storage # min_ansible_container_version:
- object-storage
- docker galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] 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,27 +7,21 @@
container: "{{ garage_service_name }}" container: "{{ garage_service_name }}"
command: /garage node id -q command: /garage node id -q
register: _garage_node_id register: _garage_node_id
changed_when: false
- name: Extract short node ID - name: Extract short node ID
ansible.builtin.set_fact: ansible.builtin.set_fact:
_garage_node_id_short: "{{ _garage_node_id.stdout.split('@')[0] }}" _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 - name: Check if node layout is configured
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ garage_service_name }}" container: "{{ garage_service_name }}"
command: /garage layout show command: /garage layout show
register: _garage_layout_show register: _garage_layout_show
failed_when: false failed_when: false
changed_when: false
- name: Check if node is in layout - name: Check if node is in layout
ansible.builtin.set_fact: ansible.builtin.set_fact:
_node_in_layout: "{{ (_garage_node_id_truncated in _garage_layout_show.stdout) or (_garage_node_id_short in _garage_layout_show.stdout) }}" _node_in_layout: "{{ _garage_node_id_short in _garage_layout_show.stdout }}"
- name: Configure garage node layout - name: Configure garage node layout
community.docker.docker_container_exec: community.docker.docker_container_exec:

View file

@ -26,77 +26,12 @@
dest: "{{ garage_docker_compose_dir }}/garage.toml" dest: "{{ garage_docker_compose_dir }}/garage.toml"
mode: '0644' mode: '0644'
- 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_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 - name: Generate bcrypt hash for webui password using htpasswd
ansible.builtin.command: ansible.builtin.shell: |
argv: htpasswd -nbBC 10 "{{ garage_webui_username }}" "{{ garage_webui_password }}"
- htpasswd register: _garage_webui_password_hash
- -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 changed_when: false
when: _garage_webui_htpasswd_active when: garage_webui_enabled
- 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 - name: Create docker-compose file for garage
template: template:

View file

@ -4,17 +4,11 @@
container: "{{ garage_service_name }}" container: "{{ garage_service_name }}"
command: /garage key list command: /garage key list
register: _existing_keys_output register: _existing_keys_output
changed_when: false
when: garage_s3_keys | length > 0 when: garage_s3_keys | length > 0
- name: Parse existing key names - name: Parse existing key names
ansible.builtin.set_fact: ansible.builtin.set_fact:
# `garage key list` columns: ID Created Name Expiration. _existing_keys: "{{ _existing_keys_output.stdout_lines[1:] | select('match', '^GK') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+)\\s+.*$', '\\1') | list }}"
# 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 when: garage_s3_keys | length > 0
- name: Create S3 keys - name: Create S3 keys
@ -33,7 +27,6 @@
command: /garage key info {{ item.name }} command: /garage key info {{ item.name }}
loop: "{{ garage_s3_keys }}" loop: "{{ garage_s3_keys }}"
register: _key_info_results register: _key_info_results
changed_when: false
when: garage_s3_keys | length > 0 when: garage_s3_keys | length > 0
- name: Extract key IDs from info - name: Extract key IDs from info
@ -49,21 +42,11 @@
container: "{{ garage_service_name }}" container: "{{ garage_service_name }}"
command: /garage bucket list command: /garage bucket list
register: _existing_buckets_output register: _existing_buckets_output
changed_when: false
when: garage_s3_keys | length > 0 when: garage_s3_keys | length > 0
- name: Parse existing bucket names - name: Parse existing bucket names
ansible.builtin.set_fact: ansible.builtin.set_fact:
# `garage bucket list` columns: ID Created Global aliases Local aliases _existing_buckets: "{{ _existing_buckets_output.stdout_lines[2:] | map('split') | map('first') | list }}"
# 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 when: garage_s3_keys | length > 0
- name: Get unique bucket names - name: Get unique bucket names
@ -81,37 +64,12 @@
- item not in _existing_buckets - item not in _existing_buckets
failed_when: false 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 - name: Set bucket permissions using key IDs
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ garage_service_name }}" container: "{{ garage_service_name }}"
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] }} command: /garage bucket allow {{ item.1.name }} {% for perm in item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.0.name] }}
loop: "{{ _bucket_info_results.results }}" loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}"
loop_control: when: garage_s3_keys | length > 0
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 # Export key credentials for use by other roles
- name: Get detailed key information for all keys - name: Get detailed key information for all keys
@ -120,7 +78,6 @@
command: /garage key info {{ item.name }} --show-secret command: /garage key info {{ item.name }} --show-secret
loop: "{{ garage_s3_keys }}" loop: "{{ garage_s3_keys }}"
register: _key_details_results register: _key_details_results
changed_when: false
when: garage_s3_keys | length > 0 when: garage_s3_keys | length > 0
- name: Build garage S3 credentials map - name: Build garage S3 credentials map

View file

@ -14,13 +14,10 @@ services:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network={{ garage_traefik_network }} - traefik.docker.network={{ garage_traefik_network }}
# S3 API endpoint # S3 API endpoint
- traefik.http.routers.{{ garage_service_name }}.rule={% for d in garage_s3_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} - traefik.http.routers.{{ garage_service_name }}.rule=Host(`{{ garage_s3_domain }}`)
{% if garage_use_ssl %} {% if garage_use_ssl %}
- traefik.http.routers.{{ garage_service_name }}.entrypoints=websecure - traefik.http.routers.{{ garage_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ garage_service_name }}.tls=true - 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 %} {% else %}
- traefik.http.routers.{{ garage_service_name }}.entrypoints=web - traefik.http.routers.{{ garage_service_name }}.entrypoints=web
{% endif %} {% endif %}
@ -38,9 +35,7 @@ services:
environment: environment:
API_BASE_URL: "http://{{ garage_service_name }}:{{ garage_admin_port }}" API_BASE_URL: "http://{{ garage_service_name }}:{{ garage_admin_port }}"
S3_ENDPOINT_URL: "http://{{ garage_service_name }}:{{ garage_s3_api_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("$", "$$") }}' AUTH_USER_PASS: '{{ _garage_webui_password_hash.stdout | replace("$", "$$") }}'
{% endif %}
volumes: volumes:
- {{ garage_docker_compose_dir }}/garage.toml:/etc/garage.toml:ro - {{ garage_docker_compose_dir }}/garage.toml:/etc/garage.toml:ro
networks: networks:
@ -53,25 +48,12 @@ services:
{% if garage_use_ssl %} {% if garage_use_ssl %}
- traefik.http.routers.{{ garage_service_name }}-console.entrypoints=websecure - traefik.http.routers.{{ garage_service_name }}-console.entrypoints=websecure
- traefik.http.routers.{{ garage_service_name }}-console.tls=true - 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 %} {% else %}
- traefik.http.routers.{{ garage_service_name }}-console.entrypoints=web - traefik.http.routers.{{ garage_service_name }}-console.entrypoints=web
{% endif %} {% endif %}
- traefik.http.routers.{{ garage_service_name }}-console.service={{ garage_service_name }}-console - traefik.http.routers.{{ garage_service_name }}-console.service={{ garage_service_name }}-console
- traefik.http.routers.{{ garage_service_name }}-console.priority=10 - traefik.http.routers.{{ garage_service_name }}-console.priority=10
- traefik.http.services.{{ garage_service_name }}-console.loadbalancer.server.port={{ garage_webui_port }} - 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 %} {% endif %}
networks: networks:

View file

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

View file

@ -1,251 +0,0 @@
# homarr
Deploy [Homarr](https://github.com/homarr-labs/homarr) as a self-contained
Docker Compose stack behind Traefik, with seeded admin user, OIDC group
and customizable application tiles.
## What this role does
- Deploys the official Homarr container with Traefik labels
- Seeds the SQLite database with:
- server settings (locale, analytics, crawling, default board)
- a default board with the three layouts (desktop/tablet/mobile)
- a local admin user with bcrypt-hashed password
- OIDC and credentials admin groups with full permissions
- application tiles defined in `homarr_apps`, auto-laid-out across all
three screen sizes via the bundled `homarr_compute_layouts` filter
- Skips the onboarding wizard so the instance is usable right after deploy
- Restarts the container via handler when the seed or compose file changes
## What this role does NOT do
- Does not configure OIDC end-to-end — set `homarr_oidc_*` variables and
configure the corresponding client in your identity provider
- Does not migrate existing Homarr databases — only seeds empty ones
- Does not create users beyond the single local admin (OIDC users are
provisioned on first login)
## Required variables
Provide via OpenBao, Ansible Vault, or extra-vars. **Never commit real
secrets to version control.**
| Variable | Format | Generate with |
|---|---|---|
| `homarr_secret_encryption_key` | 64-char hex string | `openssl rand -hex 32` |
| `homarr_admin_password` | strong password | `openssl rand -base64 24` |
| `homarr_oidc_client_secret` | from your identity provider | — |
`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
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 |
| `homarr_oidc_client_id` | empty | OIDC client id |
| `homarr_oidc_admin_group` | `homarr-admins` | Group granting admin role |
| `homarr_apps` | `[]` | List of application tiles, see below |
## Application tiles
`homarr_apps` is a list of tile definitions that are seeded into the
default board. Each entry needs:
| Field | Required | Description |
|---|---|---|
| `id` | yes | Unique slug, used as `app-<id>` and `item-<id>` |
| `name` | yes | Display name |
| `icon` | yes | Icon URL |
| `href` | yes | Click target |
| `width` | yes | Tile width in grid cells (110) |
| `description` | no | Tooltip / subtitle |
| `height` | no | Tile height (default `1`) |
The role validates that all `id` values are unique.
### Auto-layout
Tiles are packed left-to-right into three layouts:
- **Desktop**: 10 columns
- **Tablet**: 6 columns
- **Mobile**: 2 columns
When a tile does not fit the remaining width of a row, it wraps to the
next row. Tile width is clamped to the grid width (a tile with
`width: 8` becomes `width: 6` on tablet and `width: 2` on mobile).
### Example
```yaml
homarr_apps:
- id: nextcloud
name: Nextcloud
description: Cloud Storage & Collaboration
icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nextcloud.png
href: https://cloud.example.com
width: 2
- id: keycloak
name: Keycloak
description: Identity & Access Management
icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/keycloak.png
href: https://auth.example.com
width: 2
```
## Layout filter plugin
The grid-packing algorithm that places tiles on the desktop, tablet
and mobile layouts lives in `filter_plugins/homarr_layout.py` rather
than inside the Jinja seed template. This keeps the SQL template
readable and lets the algorithm be unit-tested in isolation.
The filter is invoked once from `tasks/main.yml`:
```yaml
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}"
```
This produces a `homarr_layout` fact with two keys, both consumed by
`templates/homarr_seed.sql.j2`:
| Key | Shape | Purpose |
|---|---|---|
| `apps` | list, same order as `homarr_apps` | each entry 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 | 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
`-vv` — the `Show computed app layouts` task dumps the full
`homarr_layout` fact.
### Running the filter tests
The filter is covered by unit tests in
`filter_plugins/tests/test_homarr_layout.py`:
```bash
pip install pytest ansible-core
pytest filter_plugins/tests/
```
15 tests cover packing, width clamping, height/section-height,
input validation and custom grid sizes.
## First login
After the role completes, log in at `{{ homarr_base_url }}` with:
- Username: value of `homarr_admin_username` (default `admin`)
- Password: value of `homarr_admin_password`
OIDC users are provisioned on first login if their identity provider
group matches `homarr_oidc_admin_group`. They receive admin permissions
automatically through the seeded `group-oidc-admins` group.
## Example playbook
```yaml
- name: Deploy Homarr service
hosts: homarr_servers
become: true
roles:
- digitalboard.core.homarr
```
With inventory variables:
```yaml
# inventories/<env>/group_vars/homarr_servers.yml
homarr_domain: home.digitalboard.ch
homarr_base_url: "https://home.digitalboard.ch"
homarr_auth_providers: "credentials,oidc"
homarr_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard"
homarr_oidc_client_id: "homarr-digitalboard"
homarr_oidc_client_name: "Digitalboard"
homarr_secret_encryption_key: >-
{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/homarr',
mount_point='kv').data.data.encryption_key }}
homarr_admin_password: >-
{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/homarr',
mount_point='kv').data.data.admin_password }}
homarr_oidc_client_secret: >-
{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/homarr',
mount_point='kv').data.data.oidc_client_secret }}
homarr_apps:
- id: nextcloud
name: Nextcloud
description: Cloud Storage
icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nextcloud.png
href: https://cloud.digitalboard.ch
width: 2
- id: keycloak
name: Keycloak
description: Identity & Access Management
icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/keycloak.png
href: https://auth.digitalboard.ch
width: 2
```
## Re-running the role
The role is idempotent for the typical re-run case:
- The seed only runs when no local admin user exists in the database
- The compose file and seed template are deployed via `template`,
which only changes content when the inputs change
- The restart handler only fires when one of those templates changes
If you need to re-seed an existing database (for example after deleting
the database file to apply schema changes), the role will detect the
fresh database and seed it again on the next run.
## Troubleshooting
**Login fails after deploy.** Verify that the bcrypt hash was written
correctly:
```bash
sqlite3 /srv/data/homarr/homarr/appdata/db/db.sqlite \
"SELECT id, name, email, length(password), provider FROM user;"
```
Expected: one row with `user-local-admin`, password length 60,
provider `credentials`.
**Encryption key validation fails.** The key must be exactly 64
characters and contain only hex digits (`[a-fA-F0-9]`). Both upper-
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.
## License
MIT-0

View file

@ -1,82 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# defaults file for homarr
# Base directory configuration (inherited from base role or defined here)
docker_compose_base_dir: /etc/docker/compose
docker_volume_base_dir: /srv/data
# homarr-specific configuration
homarr_base_path: /srv/data/homarr
homarr_docker_compose_dir: "{{ docker_compose_base_dir }}/homarr"
homarr_docker_volume_dir: "{{ docker_volume_base_dir }}/homarr"
homarr_appdata_dir: "{{ homarr_docker_volume_dir }}/homarr/appdata"
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
# REQUIRED: 64-character hex string used to encrypt integration credentials.
# Generate with: openssl rand -hex 32
# Provide via OpenBao lookup, Ansible Vault, or extra-vars.
# Never commit a real key to version control.
homarr_secret_encryption_key: ""
# URL — used for BASE_URL, NEXTAUTH_URL and the completion message
homarr_base_url: "https://home.local.test"
# Auth providers (comma-separated): credentials, oidc, ldap
homarr_auth_providers: "credentials"
# OIDC configuration (only used when 'oidc' is in homarr_auth_providers)
homarr_oidc_issuer: ""
homarr_oidc_client_id: ""
homarr_oidc_client_name: ""
homarr_oidc_scopes: "openid profile email groups"
homarr_oidc_groups_attribute: "groups"
homarr_oidc_client_secret: ""
homarr_oidc_auto_login: "false"
# OIDC admin group (must exist in the identity provider)
homarr_oidc_admin_group: "homarr-admins"
# Board configuration
homarr_default_board_name: "Home"
homarr_default_board_public: true
# Traefik configuration
homarr_traefik_network: "proxy"
homarr_use_ssl: true
# Local admin (override in inventory or via vault)
homarr_admin_username: "admin"
homarr_admin_email: "admin@example.com"
homarr_admin_password: "ChangeMe123!"
# Applications shown on the default board.
# Override in your project/inventory vars. Each app needs:
# id, name, icon, href, width (1-10). Optional: description, height (default 1).
# Apps are automatically packed left-to-right into the desktop grid (10 cols),
# scaled to tablet (6 cols) and mobile (2 cols).
#
# Example:
# homarr_apps:
# - id: nextcloud
# name: Nextcloud
# description: Cloud Storage & Collaboration
# icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nextcloud.png
# href: https://cloud.example.com
# width: 2
# height: 1
homarr_apps: []

View file

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

View file

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

View file

@ -1,27 +0,0 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: digitalboard
description: Deploy the Homarr dashboard via Docker Compose behind Traefik, with seeded admin user and OIDC group
company: Digitalboard
license: MIT-0
min_ansible_version: "2.14"
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
galaxy_tags:
- homarr
- dashboard
- oidc
- docker
- traefik
- digitalboard
dependencies: []

View file

@ -1,162 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# tasks file for homarr
# =====================================================================
# 0. VALIDATION
# =====================================================================
- name: Validate encryption key
ansible.builtin.assert:
that:
- homarr_secret_encryption_key | length == 64
- homarr_secret_encryption_key is match('^[a-fA-F0-9]+$')
fail_msg: >-
homarr_secret_encryption_key must be a 64-character hex string.
Generate with: openssl rand -hex 32
Provide via OpenBao, Ansible Vault or extra-vars.
success_msg: Encryption key validation passed
- name: Validate OIDC configuration when enabled
ansible.builtin.assert:
that:
- homarr_oidc_client_secret | length > 0
fail_msg: >-
homarr_oidc_client_secret must be set when 'oidc' is in homarr_auth_providers.
Set via OpenBao or remove 'oidc' from homarr_auth_providers.
when: "'oidc' in homarr_auth_providers"
- name: Validate homarr_apps have unique ids
ansible.builtin.assert:
that:
- homarr_apps | map(attribute='id') | list | length ==
homarr_apps | map(attribute='id') | unique | list | length
fail_msg: >-
homarr_apps contains duplicate ids.
Each app must have a unique 'id'. Got:
{{ homarr_apps | map(attribute='id') | list }}
success_msg: All app ids are unique
when: homarr_apps | length > 0
# =====================================================================
# 1. PREPARATION: packages and directories before container start
# =====================================================================
- name: Ensure required packages are installed
ansible.builtin.package:
name:
- sqlite3
- python3-docker
state: present
- name: Create docker compose directory
ansible.builtin.file:
path: "{{ homarr_docker_compose_dir }}"
state: directory
mode: '0755'
- name: Create Homarr data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "1000"
group: "1000"
mode: "0755"
loop:
- "{{ homarr_appdata_dir }}"
- "{{ homarr_appdata_dir }}/db"
- name: Check if database already exists
ansible.builtin.stat:
path: "{{ homarr_db }}"
register: db_exists
# =====================================================================
# 2. START CONTAINER
# =====================================================================
- name: Create docker-compose file for homarr
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ homarr_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
- name: Start homarr containers
community.docker.docker_compose_v2:
project_src: "{{ homarr_docker_compose_dir }}"
state: present
# =====================================================================
# 3. WAIT FOR DATABASE
# =====================================================================
- name: Wait for database to be created by Homarr
ansible.builtin.wait_for:
path: "{{ homarr_db }}"
state: present
timeout: 60
when: not db_exists.stat.exists
- name: Wait for database schema to be initialized
ansible.builtin.command:
cmd: sqlite3 "{{ homarr_db }}" "SELECT name FROM sqlite_master WHERE type='table' AND name='board';"
register: schema_check
until: schema_check.stdout == "board"
retries: 30
delay: 2
changed_when: false
when: not db_exists.stat.exists
# =====================================================================
# 4. GENERATE BCRYPT HASH (on controller, not on target)
# =====================================================================
- name: Generate bcrypt hash for admin password
ansible.builtin.set_fact:
# 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
# =====================================================================
# 5. COMPUTE APP LAYOUTS
# =====================================================================
# Packing is done by the homarr_compute_layouts filter plugin (Python)
# rather than inline Jinja, so the seed template stays readable and the
# packing algorithm can be unit-tested in isolation.
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}"
- name: Show computed app layouts
ansible.builtin.debug:
var: homarr_layout
verbosity: 1
# =====================================================================
# 6. SEED DATABASE (only if local admin user does not exist yet)
# =====================================================================
- name: Check if local admin user exists
ansible.builtin.command:
cmd: sqlite3 "{{ homarr_db }}" "SELECT id FROM user WHERE id='user-local-admin';"
register: admin_exists
changed_when: false
failed_when: false
- name: Seed Homarr database
ansible.builtin.command:
cmd: sqlite3 "{{ homarr_db }}"
stdin: "{{ lookup('template', 'homarr_seed.sql.j2') }}"
register: seed_result
changed_when: seed_result.rc == 0
when: admin_exists.stdout == ""
notify: restart homarr

View file

@ -1,51 +0,0 @@
#---------------------------------------------------------------------#
# Homarr — A simple, yet powerful dashboard for your server. #
#---------------------------------------------------------------------#
services:
homarr:
container_name: homarr
image: {{ homarr_image }}
restart: unless-stopped
volumes:
{% if homarr_use_docker %}
- /var/run/docker.sock:/var/run/docker.sock
{% endif %}
- {{ homarr_docker_volume_dir }}/homarr/appdata:/appdata
environment:
TZ: "Europe/Zurich"
BASE_URL: "{{ homarr_base_url }}"
NEXTAUTH_URL: "{{ homarr_base_url }}"
SECRET_ENCRYPTION_KEY: "{{ homarr_secret_encryption_key }}"
AUTH_PROVIDERS: "{{ homarr_auth_providers }}"
AUTH_OIDC_ISSUER: "{{ homarr_oidc_issuer }}"
AUTH_OIDC_CLIENT_ID: "{{ homarr_oidc_client_id }}"
AUTH_OIDC_CLIENT_SECRET: "{{ homarr_oidc_client_secret }}"
AUTH_OIDC_CLIENT_NAME: "{{ homarr_oidc_client_name | default('Keycloak') }}"
AUTH_OIDC_SCOPE_OVERWRITE: "{{ homarr_oidc_scopes | default('openid email profile groups') }}"
AUTH_OIDC_GROUPS_ATTRIBUTE: "{{ homarr_oidc_groups_attribute | default('groups') }}"
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={% 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 %}
- traefik.http.services.homarr.loadbalancer.server.port={{ homarr_port }}
networks:
{{ homarr_traefik_network }}:
external: true

View file

@ -1,175 +0,0 @@
{#-
Homarr database seed.
The packing algorithm previously lived in this template as a Jinja
`pack()` macro with from_json/to_json round-trips. It has been
extracted to the `homarr_compute_layouts` filter plugin (see
filter_plugins/homarr_layout.py) and the result is provided as the
`homarr_layout` fact set in tasks/main.yml. This template therefore
only renders SQL — no logic.
-#}
BEGIN TRANSACTION;
-- =====================================================================
-- SERVER SETTINGS
-- =====================================================================
INSERT OR REPLACE INTO serverSetting (setting_key, value)
VALUES
('analytics', '{"json": {"enableGeneral": false, "enableWidgetData": false, "enableIntegrationData": false, "enableUserData": false}}'),
('culture', '{"json": {"defaultLocale": "de"}}'),
('crawling', '{"json": {"crawlingEnabled": false}}'),
('board', '{"json": {"homeBoardId": "board-default", "mobileHomeBoardId": "board-default", "enableStatusByDefault": true, "forceDisableStatus": false, "defaultBoardId": "board-default"}}');
-- Skip onboarding wizard
UPDATE onboarding SET step = 'finish', previous_step = 'settings';
-- =====================================================================
-- GROUPS (must exist before groupMember)
-- =====================================================================
-- OIDC admin group
INSERT OR IGNORE INTO "group" (id, name, owner_id, position)
VALUES ('group-oidc-admins', '{{ homarr_oidc_admin_group }}', NULL, 0);
INSERT OR IGNORE INTO groupPermission (group_id, permission)
VALUES
('group-oidc-admins', 'admin'),
('group-oidc-admins', 'board-create'),
('group-oidc-admins', 'board-full-access'),
('group-oidc-admins', 'integration-create'),
('group-oidc-admins', 'integration-full-access');
-- Credentials admin group
INSERT OR IGNORE INTO "group" (id, name, owner_id, position)
VALUES ('group-credentials-admin', 'credentials-admin', NULL, 1);
INSERT OR IGNORE INTO groupPermission (group_id, permission)
VALUES
('group-credentials-admin', 'admin'),
('group-credentials-admin', 'board-create'),
('group-credentials-admin', 'board-full-access'),
('group-credentials-admin', 'integration-create'),
('group-credentials-admin', 'integration-full-access');
-- =====================================================================
-- LOCAL ADMIN USER
-- =====================================================================
INSERT OR IGNORE INTO user (id, name, email, password, email_verified, provider)
VALUES (
'user-local-admin',
'{{ homarr_admin_username }}',
'{{ homarr_admin_email }}',
'{{ homarr_bcrypt_hash }}',
1,
'credentials'
);
-- Assign admin user to groups
INSERT OR IGNORE INTO groupMember (group_id, user_id)
VALUES
('group-credentials-admin', 'user-local-admin'),
('group-oidc-admins', 'user-local-admin');
-- =====================================================================
-- BOARD
-- =====================================================================
INSERT OR IGNORE INTO board (
id, name, is_public,
primary_color, secondary_color, opacity,
background_image_attachment, background_image_repeat, background_image_size,
item_radius, disable_status
)
VALUES (
'board-default',
'{{ homarr_default_board_name }}',
{% if homarr_default_board_public %}1{% else %}0{% endif %},
'#fa5252',
'#fd7e14',
100,
'fixed',
'no-repeat',
'cover',
'lg',
0
);
-- Layouts
INSERT OR IGNORE INTO layout (id, name, board_id, column_count, breakpoint)
VALUES
('layout-desktop', 'Desktop', 'board-default', 10, 0),
('layout-tablet', 'Tablet', 'board-default', 6, 768),
('layout-mobile', 'Mobile', 'board-default', 2, 480);
-- Set home board for admin user (board exists now)
UPDATE user SET home_board_id = 'board-default', mobile_home_board_id = 'board-default'
WHERE id = 'user-local-admin';
-- =====================================================================
-- SECTION
-- =====================================================================
DELETE FROM section_layout WHERE section_id = 'section-apps';
DELETE FROM item_layout WHERE section_id = 'section-apps';
DELETE FROM section WHERE id = 'section-apps';
INSERT INTO section (id, board_id, kind, x_offset, y_offset, name, options)
VALUES (
'section-apps',
'board-default',
'empty',
0,
0,
'Applications',
'{"json": {}}'
);
-- Section height is sized to fit the computed layout (see
-- homarr_compute_layouts filter). It grows automatically when more
-- apps or taller tiles are added.
INSERT OR REPLACE INTO section_layout (section_id, layout_id, parent_section_id, x_offset, y_offset, width, height)
VALUES
('section-apps', 'layout-desktop', NULL, 0, 0, 10, {{ homarr_layout.section_height.desktop }}),
('section-apps', 'layout-tablet', NULL, 0, 0, 6, {{ homarr_layout.section_height.tablet }}),
('section-apps', 'layout-mobile', NULL, 0, 0, 2, {{ homarr_layout.section_height.mobile }});
-- Board permissions
INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission)
VALUES
('board-default', 'group-oidc-admins', 'full-access'),
('board-default', 'group-credentials-admin', 'full-access');
-- =====================================================================
-- APPS (positions pre-computed by homarr_compute_layouts filter)
-- =====================================================================
{% for app in homarr_layout.apps %}
-- {{ app.name }}
INSERT OR IGNORE INTO app (id, name, description, icon_url, href)
VALUES (
'app-{{ app.id }}',
'{{ app.name }}',
'{{ app.description | default("") }}',
'{{ app.icon }}',
'{{ app.href }}'
);
INSERT OR IGNORE INTO item (id, board_id, kind, options, advanced_options)
VALUES (
'item-{{ app.id }}',
'board-default',
'app',
'{"json": {"appId": "app-{{ app.id }}"}}',
'{"json": {}}'
);
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES
('item-{{ app.id }}', 'section-apps', 'layout-desktop', {{ app.desktop.x }}, {{ app.desktop.y }}, {{ app.desktop.w }}, {{ app.desktop.h }}),
('item-{{ app.id }}', 'section-apps', 'layout-tablet', {{ app.tablet.x }}, {{ app.tablet.y }}, {{ app.tablet.w }}, {{ app.tablet.h }}),
('item-{{ app.id }}', 'section-apps', 'layout-mobile', {{ app.mobile.x }}, {{ app.mobile.y }}, {{ app.mobile.w }}, {{ app.mobile.h }});
{% endfor %}
COMMIT;

View file

@ -1,30 +1,38 @@
# httpbin Role Name
=========
Deploys [httpbin](https://httpbin.org/) (`kennethreitz/httpbin`) via A brief description of the role goes here.
Docker Compose behind Traefik. Useful as a throwaway endpoint to verify
that the Traefik ingress path, TLS and routing work end to end.
## Role variables Requirements
------------
| Variable | Default | Description | 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.
| --- | --- | --- |
| `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`). |
## Example Role Variables
--------------
```yaml 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.
- hosts: services
become: true Dependencies
------------
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: roles:
- role: digitalboard.core.httpbin - { role: username.rolename, x: 42 }
vars:
httpbin_domain: "httpbin.example.com"
```
## License License
-------
MIT-0 BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy httpbin HTTP request/response testing service via Docker Compose behind Traefik description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- httpbin
- testing # If this a Container Enabled role, provide the minimum Ansible Container version.
- debug # min_ansible_container_version:
- docker
- traefik galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] 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,119 +1,65 @@
# Keycloak Keycloak
=========
Ansible role to deploy Keycloak with a PostgreSQL backend via Docker Ansible role to deploy Keycloak with PostgreSQL database using Docker Compose.
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 on the target host (e.g. via - Docker and Docker Compose installed on the target host
`digitalboard.core.base`) - Ansible collection: `community.docker`
- Ansible collections: `community.docker`, and `community.general` when - Traefik reverse proxy (for external access)
`keycloak_provisioning_enabled` is true
- Traefik reverse proxy with the `proxy` network already created (for
external access)
## Role variables Role Variables
--------------
Key variables from `defaults/main.yml`: Key variables defined in `defaults/main.yml`:
### Base configuration **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`)
| Variable | Default | Description | **Keycloak Configuration:**
| --- | --- | --- | - `keycloak_service_name`: Service name (default: `keycloak`)
| `docker_compose_base_dir` | `/etc/docker/compose` | Base dir for Compose projects. | - `keycloak_domain`: Domain name for Keycloak (default: `auth.digitalboard.ch`)
| `docker_volume_base_dir` | `/srv/data` | Base dir for persistent volumes. | - `keycloak_image`: Keycloak Docker image (default: `quay.io/keycloak/keycloak:24.0.1`)
| `keycloak_service_name` | `keycloak` | Compose/service name; builds the per-service paths. | - `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`)
### Keycloak **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`)
| Variable | Default | Description | **Traefik Configuration:**
| --- | --- | --- | - `keycloak_traefik_network`: Traefik network name (default: `proxy`)
| `keycloak_domain` | `keycloak.local.test` | Host rule and `KC_HOSTNAME`. | - `keycloak_backend_network`: Backend network name (default: `backend`)
| `keycloak_image` | `quay.io/keycloak/keycloak:24.0.1` | Keycloak image. | - `keycloak_use_ssl`: Enable SSL (default: `true`)
| `keycloak_port` | `8080` | Internal HTTP port advertised to Traefik. | - `keycloak_cert_resolver`: Certificate resolver name (default: `dns`)
| `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. |
### PostgreSQL Dependencies
------------
| Variable | Default | Description | This role requires the Traefik reverse proxy to be configured and the `proxy` network to be created.
| --- | --- | --- |
| `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**. |
### Traefik Example Playbook
----------------
| 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 ```yaml
- hosts: backend_servers - hosts: backend_servers
roles: roles:
- role: digitalboard.core.keycloak - role: keycloak
vars: vars:
keycloak_domain: "auth.example.com" keycloak_domain: "auth.example.com"
keycloak_admin_password: "{{ vault_keycloak_admin_password }}" keycloak_admin_password: "secure_password"
keycloak_postgres_password: "{{ vault_keycloak_pg_password }}" keycloak_postgres_password: "secure_db_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 MIT-0

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy Keycloak with a PostgreSQL backend via Docker Compose behind Traefik description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # 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
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- keycloak
- oidc # If this a Container Enabled role, provide the minimum Ansible Container version.
- sso # min_ansible_container_version:
- docker
- traefik galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

38
roles/knot/README.md Normal file
View file

@ -0,0 +1,38 @@
Role Name
=========
A brief description of the role goes here.
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.
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.
Dependencies
------------
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).

View file

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

View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# handlers file for knot

35
roles/knot/meta/main.yml Normal file
View file

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

View file

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

View file

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

View file

@ -3,4 +3,4 @@
- hosts: localhost - hosts: localhost
remote_user: root remote_user: root
roles: roles:
- talk - knot

View file

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

View file

@ -1,124 +0,0 @@
# 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,17 +9,11 @@ nextcloud_service_name: nextcloud
nextcloud_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ nextcloud_service_name }}" nextcloud_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ nextcloud_service_name }}"
nextcloud_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ nextcloud_service_name }}" nextcloud_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ nextcloud_service_name }}"
# FQDNs the nextcloud router accepts. The first entry is the canonical nextcloud_domain: "nextcloud.local.test"
# 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_image: "nextcloud:fpm"
nextcloud_redis_image: "redis:latest" nextcloud_redis_image: "redis:latest"
nextcloud_port: 80 nextcloud_port: 80
nextcloud_extra_hosts: [] nextcloud_extra_hosts: []
nextcloud_extra_networks: []
nextcloud_allow_local_remote_servers: false # Set to true to allow requests to local network (dev only) nextcloud_allow_local_remote_servers: false # Set to true to allow requests to local network (dev only)
nextcloud_postgres_image: "postgres:15" nextcloud_postgres_image: "postgres:15"
@ -64,33 +58,6 @@ nextcloud_trusted_proxies: "172.16.0.0/12"
# File locking and real-time push notifications # File locking and real-time push notifications
nextcloud_enable_notify_push: false 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.
nextcloud_enable_talk: false
# HPB signaling servers to register.
# Each item: { server: "https://signaling.example.test", secret: "<hpb_shared_secret>", verify: true }
nextcloud_talk_signaling_servers: []
# Hostnames/URLs to remove via `talk:signaling:delete` before adding the new set.
nextcloud_talk_signaling_servers_removed: []
# TURN servers to register.
# Each item: { server: "stun.example.test:443", secret: "<turn_shared_secret>", schemes: "turn,turns", protocols: "udp,tcp" }
nextcloud_talk_turn_servers: []
# Clear the spreed.turn_servers config key before re-adding (single source of truth)
nextcloud_talk_turn_reset_before_add: true
# Plain STUN endpoints (host:port). Optional; usually not needed if TURN servers handle STUN too.
nextcloud_talk_stun_servers: []
nextcloud_talk_stun_servers_removed: []
# Non-default apps to install and enable # Non-default apps to install and enable
nextcloud_apps_to_install: nextcloud_apps_to_install:

View file

@ -1,253 +0,0 @@
---
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

@ -1,28 +0,0 @@
#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,55 +1,22 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
--- ---
# tasks file for configuring Collabora in Nextcloud # tasks file for configuring Collabora in Nextcloud
- name: Read current richdocuments config values - name: Configure Collabora WOPI URL
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: community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" 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 }} 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 - name: Configure certificate verification for Collabora
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" 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') }} 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 - name: Set Collabora WOPI allowlist
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set richdocuments wopi_allowlist --value='' 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) - name: Activate richdocuments configuration (fetch discovery from Collabora)
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" 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,41 +2,18 @@
--- ---
# tasks file for configuring draw.io in Nextcloud # 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 - name: Configure draw.io URL
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set drawio DrawioUrl --value={{ nextcloud_drawio_url }} command: php /var/www/html/occ config:app:set drawio DrawioUrl --value={{ nextcloud_drawio_url }}
when: when: nextcloud_drawio_url | length > 0
- nextcloud_drawio_url | length > 0
- _drawio_cfg.DrawioUrl != nextcloud_drawio_url
- name: Configure draw.io theme - name: Configure draw.io theme
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
command: php /var/www/html/occ config:app:set drawio DrawioTheme --value={{ nextcloud_drawio_theme }} 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 - name: Configure draw.io offline mode
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" 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,24 +15,6 @@
command: php /var/www/html/occ ldap:create-empty-config command: php /var/www/html/occ ldap:create-empty-config
when: "'s01' not in ldap_show_config.stdout" 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 - name: Configure LDAP settings
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: "{{ nextcloud_service_name }}-nextcloud-1" container: "{{ nextcloud_service_name }}-nextcloud-1"
@ -47,7 +29,6 @@
loop_control: loop_control:
label: "{{ item.key }}" label: "{{ item.key }}"
no_log: true no_log: true
when: ((_ldap_current | default({})).get(item.key) | default(none) | string) != (item.value | string)
- name: Test LDAP configuration - name: Test LDAP configuration
community.docker.docker_container_exec: community.docker.docker_container_exec:

View file

@ -19,12 +19,6 @@
state: directory state: directory
mode: '0755' mode: '0755'
- name: Ensure extra networks exist
community.docker.docker_network:
name: "{{ item }}"
state: present
loop: "{{ nextcloud_extra_networks }}"
- name: Create docker-compose file for nextcloud - name: Create docker-compose file for nextcloud
template: template:
src: docker-compose.yml.j2 src: docker-compose.yml.j2
@ -49,61 +43,6 @@
project_src: "{{ nextcloud_docker_compose_dir }}" project_src: "{{ nextcloud_docker_compose_dir }}"
state: present 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 - name: Wait for Nextcloud to be ready
ansible.builtin.shell: ansible.builtin.shell:
cmd: docker compose exec -T nextcloud php /var/www/html/occ status --output=json cmd: docker compose exec -T nextcloud php /var/www/html/occ status --output=json
@ -146,7 +85,3 @@
- name: Configure OIDC providers - name: Configure OIDC providers
ansible.builtin.include_tasks: oidc.yml ansible.builtin.include_tasks: oidc.yml
when: nextcloud_oidc_providers | length > 0 or nextcloud_oidc_providers_removed | length > 0 when: nextcloud_oidc_providers | length > 0 or nextcloud_oidc_providers_removed | length > 0
- name: Configure Nextcloud Talk (HPB + TURN + STUN)
ansible.builtin.include_tasks: talk.yml
when: nextcloud_enable_talk

Some files were not shown because too many files have changed in this diff Show more