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
parent 78095cca1d
commit 9d539d0da4
16 changed files with 659 additions and 2 deletions

View file

@ -0,0 +1,41 @@
#!/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

@ -0,0 +1,12 @@
# {{ 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

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

View file

@ -0,0 +1,87 @@
#---------------------------------------------------------------------#
# 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
depends_on:
{{ bookstack_service_name }}-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network={{ bookstack_traefik_network }}"
- "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)"
- "traefik.http.routers.{{ bookstack_service_name }}.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