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.
This commit is contained in:
Tobias Wüst 2026-05-20 17:39:16 +02:00 committed by Simon Bärlocher
parent 611964f7d6
commit 4fe9d6b177
No known key found for this signature in database
GPG key ID: 63DE20495932047A
16 changed files with 664 additions and 2 deletions

View file

@ -0,0 +1,223 @@
#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)
- (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0)
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