digitalboard.core/roles/bookstack/README.md
Tobias Wüst 4fe9d6b177
feat(bookstack): add role for self-hosted BookStack deployment
Deploy BookStack with linuxserver.io images behind Traefik, including
Entra ID OIDC SSO support and a daily backup timer.

Stack:
- lscr.io/linuxserver/bookstack:version-v26.03.3
- lscr.io/linuxserver/mariadb:11.4.9
- Traefik labels for websecure entrypoint on internal network
- Healthcheck via mariadb-admin ping (LSIO image lacks healthcheck.sh)

Features:
- Persistent APP_KEY generated on first run, stored in volume dir
- Optional OIDC SSO via Microsoft Entra ID (configurable per-instance)
- Idempotent admin user creation with DB-based existence check
- Daily systemd timer backup (DB dump + uploads tar + APP_KEY)
  with configurable retention

Implementation notes:
- DB queries use --protocol=tcp with the app user because root@localhost
  uses unix_socket auth in the LSIO MariaDB image (no password) and
  root@% does not exist
- docker_container_exec uses argv: (list) instead of command: (string)
  to avoid argument-splitting issues
- Migration-wait task ensures users table exists before admin check,
  since /login returns 200 before Laravel migrations complete
- no_log: true on all tasks that reference DB or admin passwords
- artisan absolute path (/app/www/artisan) because LSIO image WORKDIR
  is not the app directory

Adds bookstack route to DMZ Traefik service registry.
2026-05-26 16:16:35 +02:00

5.9 KiB

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 Entra ID App Registration ID (if OIDC on)
bookstack_oidc_client_secret Entra ID client secret (if OIDC on)
bookstack_entra_tenant_id Entra tenant UUID (if OIDC on)

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_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