digitalboard.core/roles/bookstack
Simon Bärlocher 19864d79b2
feat(services): multi-domain routing, split-horizon and OIDC hardening
Bundle of cross-role changes for the gymb services deployment:

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

Docs: READMEs and meta/argument_specs.yml updated for all new variables.
2026-05-27 23:12:24 +02:00
..
defaults feat(services): multi-domain routing, split-horizon and OIDC hardening 2026-05-27 23:12:24 +02:00
handlers feat(bookstack): add role for self-hosted BookStack deployment 2026-05-26 15:30:21 +02:00
meta feat(services): multi-domain routing, split-horizon and OIDC hardening 2026-05-27 23:12:24 +02:00
tasks feat(services): multi-domain routing, split-horizon and OIDC hardening 2026-05-27 23:12:24 +02:00
templates feat(services): multi-domain routing, split-horizon and OIDC hardening 2026-05-27 23:12:24 +02:00
tests feat(bookstack): add role for self-hosted BookStack deployment 2026-05-26 15:30:21 +02:00
vars feat(bookstack): add role for self-hosted BookStack deployment 2026-05-26 15:30:21 +02:00
README.md feat(services): multi-domain routing, split-horizon and OIDC hardening 2026-05-27 23:12:24 +02:00

Ansible Role: bookstack

Deploys BookStack 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

- name: Deploy BookStack service
  hosts: bookstack_servers
  become: true
  roles:
    - digitalboard.core.bookstack

With inventory variables:

# 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:
    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