Add Pressbooks: self-hosted book platform on WordPress Multisite

Dedicated WordPress Multisite install (never shared with wordpress.sh,
since Pressbooks requires a fresh network) with a custom image adding
mod_rewrite/AllowOverride, Ghostscript/ImageMagick/poppler-utils for the
cover generator, and an optional PrinceXML install for PDF export
(DocRaptor offered as a SaaS alternative). Chapters use WordPress's own
block editor, which already supports drag-and-drop image placement.
Gated by Authelia SSO via the standard forward_auth pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
This commit is contained in:
Claude
2026-08-30 21:38:32 +00:00
parent b427127200
commit 697ee95461
2 changed files with 791 additions and 1 deletions
+2 -1
View File
@@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
@@ -240,6 +240,7 @@ utilities
onlyoffice
paintplus
portainer
pressbooks
rustdesk
stirling-pdf
syncthing
+789
View File
@@ -0,0 +1,789 @@
#!/bin/bash
# services/pressbooks.sh — Self-hosted Pressbooks: write/import books, drag-and-drop
# image placement, export to PDF/EPUB for professional or personal printing.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash pressbooks.sh
# (Docker must already be installed when run standalone)
#
# Pressbooks is a WordPress Multisite plugin/theme suite, not a normal
# WordPress plugin — its own docs are explicit that it "should be used with
# a fresh, multisite WordPress installation" and is "not for use on an
# existing blog." That means it can never be layered onto an existing
# services/wordpress.sh site: it gets its own dedicated WordPress core,
# database, and container here, converted to a Multisite network as part of
# this installer instead of a plain single-site install.
#
# Not following the multi-instance pattern documented in CLAUDE.md: that
# pattern exists for services that are inherently single-tenant per
# install. Pressbooks is the opposite — a single network already hosts any
# number of independent books (each its own site in the network, its own
# authors, its own theme), which is exactly the multi-tenancy the pattern
# gives other services. A second, fully separate Pressbooks *network* would
# only matter for something like two unrelated publishing organizations
# wanting entirely separate admin/user databases on one box — a much rarer
# need than "another book" — so it's left out of scope here.
#
# Chapters are written and images placed via WordPress's own block editor
# (Gutenberg) — dragging an image file into a chapter's content area drops
# an Image block at that position, and the block editor's own "Add Media"
# dialog also accepts drag-and-drop uploads. This is native WordPress
# behavior, not a Pressbooks feature, so it needs no extra plugin here.
#
# PDF export needs a rendering engine Pressbooks itself doesn't ship:
# - PrinceXML, installed on this container — free for non-commercial use
# (adds a small logo to page 1 of every PDF), full price for a
# commercial/watermark-free license. See install_pressbooks' Dockerfile
# generation below.
# - DocRaptor, PrinceXML as a paid SaaS API (DOCRAPTOR_API_KEY) — no local
# binary to maintain, but not free for real (non-watermarked) documents.
# - mPDF, Pressbooks' third documented option, is explicitly unmaintained
# upstream — not offered here.
# EPUB export needs no extra engine (Pressbooks generates it directly) and
# needs EPUBCheck's dependencies below. MOBI export was removed from
# Pressbooks entirely after Amazon discontinued KindleGen and stopped
# accepting MOBI on KDP (March 2025) — not offered here; see the generated
# README for the EPUB→MOBI-via-Calibre workaround for personal Kindle use.
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Existing install detected. Choose:"
echo " u) Update — refresh plugin/theme/image, keep books and settings"
echo " f) Full reinstall — re-run every prompt from scratch"
echo " c) Cancel — leave everything as-is [default]"
read -r -p " Choice [u/f/c, Enter=cancel]: " _r
case "${_r,,}" in
u) eval "$_var='update'" ;;
f) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
CADDY_SERVICE_CONFIGURED=false
CADDY_SERVICE_MODE=""
CADDY_SERVICE_DOMAIN=""
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
local _block_upstream="$_upstream"
[[ "$_mode" == "remote" ]] && _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
${_extra}
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; CADDY_SERVICE_CONFIGURED=true; CADDY_SERVICE_MODE="local"; CADDY_SERVICE_DOMAIN="$_domain"; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
CADDY_SERVICE_CONFIGURED=true
CADDY_SERVICE_MODE="local"
CADDY_SERVICE_DOMAIN="$_domain"
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
fi
else
CADDY_SERVICE_CONFIGURED=true
CADDY_SERVICE_MODE="remote"
CADDY_SERVICE_DOMAIN="$_domain"
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service pressbooks utilities "Self-hosted Pressbooks — write/import books with drag-and-drop images, export to PDF/EPUB (Authelia SSO gate)" 8095
# Fetches the newest release of a pressbooks/<repo> GitHub project as an
# installable zip URL, for wp-cli's own "plugin install <url>"/"theme install
# <url>" (which download and unpack it itself — nothing here needs to know
# how to unzip a WordPress plugin). Prefers an actual release asset (the
# packaged, ready-to-install zip these projects publish, vendor/ dependencies
# included) and falls back to the tagged source archive GitHub always
# generates automatically if no asset is found — that fallback can be
# missing composer's vendor/ directory, so it's logged with a warning
# rather than silently swapped in.
_pressbooks_latest_zip_url() {
local _repo="$1" _api _url _tag
_api="$(curl -fsSL "https://api.github.com/repos/pressbooks/${_repo}/releases/latest" 2>/dev/null)"
_url="$(printf '%s' "$_api" | grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*\.zip"' | head -1 | grep -o 'https://[^"]*')"
if [ -z "$_url" ]; then
_tag="$(printf '%s' "$_api" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)"
if [ -n "$_tag" ]; then
_url="https://github.com/pressbooks/${_repo}/archive/refs/tags/${_tag}.zip"
log_warning "No packaged release asset found for ${_repo} — falling back to its" >&2
log_warning "tagged source archive, which can be missing composer's vendor/ directory." >&2
fi
fi
printf '%s' "$_url"
}
# A release zip's top-level directory sometimes carries a version suffix
# (especially the tagged-source-archive fallback above, e.g.
# "pressbooks-book-2.5.0/" instead of "pressbooks-book/") — WP-CLI's own
# "theme enable"/"plugin activate --network" need the directory to be named
# exactly the plugin/theme slug to find it at all. Renames it into place if
# a mismatch is found; a no-op if the zip already unpacked to the right name.
_pressbooks_normalize_slug() {
local _html_dir="$1" _kind="$2" _slug="$3"
docker run --rm -v "${_html_dir}:/var/www/html" alpine sh -c "
cd /var/www/html/wp-content/${_kind} 2>/dev/null || exit 0
[ -d '${_slug}' ] && exit 0
d=\$(ls -d ${_slug}-* 2>/dev/null | head -1)
[ -n \"\$d\" ] && mv \"\$d\" '${_slug}'
exit 0
" >/dev/null 2>&1
}
# Installs/refreshes the Pressbooks plugin and its three companion themes
# (McLuhan/pressbooks-book — the default book theme; Aldine — the default
# root theme; Publisher — the default theme for the network's own landing
# site) network-wide, and activates Publisher on the root site. Shared by
# the fresh-install path and the "update" rerun path (CLAUDE.md's
# non-destructive-update convention: this only ever touches plugin/theme
# code, never wp-config.php, .env, or any book's own content/DB rows).
_pressbooks_install_plugins_and_themes() {
local _dir="$1" _net="$2" _port="$3"
_pb_wpcli() { docker run --rm --network "$_net" -v "${_dir}/html:/var/www/html" --env-file "${_dir}/.env" wordpress:cli "$@"; }
local _plugin_url _book_url _aldine_url _publisher_url
_plugin_url="$(_pressbooks_latest_zip_url pressbooks)"
_book_url="$(_pressbooks_latest_zip_url pressbooks-book)"
_aldine_url="$(_pressbooks_latest_zip_url pressbooks-aldine)"
_publisher_url="$(_pressbooks_latest_zip_url pressbooks-publisher)"
if [ -z "$_plugin_url" ]; then
log_error "Couldn't determine a Pressbooks download URL from GitHub (API unreachable or rate-limited)."
log_error "Install by hand instead: download a release zip from"
log_error " https://github.com/pressbooks/pressbooks/releases"
log_error "then, in Network Admin -> Plugins -> Add New -> Upload Plugin, upload it and Network Activate."
return 1
fi
log_info "Installing Pressbooks plugin..."
_pb_wpcli plugin install "$_plugin_url" --force || log_warning "Pressbooks plugin install reported an error — see docker compose logs."
_pressbooks_normalize_slug "${_dir}/html" plugins pressbooks
_pb_wpcli plugin activate pressbooks --network || log_warning "Network-activating Pressbooks failed — do it by hand in Network Admin -> Plugins."
log_info "Installing Pressbooks themes (McLuhan, Aldine, Publisher)..."
for _pair in "pressbooks-book:$_book_url" "pressbooks-aldine:$_aldine_url" "pressbooks-publisher:$_publisher_url"; do
local _slug="${_pair%%:*}" _url="${_pair#*:}"
[ -z "$_url" ] && { log_warning "Couldn't determine a download URL for theme $_slug — skipping."; continue; }
_pb_wpcli theme install "$_url" --force || log_warning "Theme $_slug install reported an error."
_pressbooks_normalize_slug "${_dir}/html" themes "$_slug"
_pb_wpcli theme enable "$_slug" || log_warning "Network-enabling $_slug failed — do it by hand in Network Admin -> Themes."
done
_pb_wpcli theme activate pressbooks-publisher --url="http://localhost:${_port}" \
|| log_warning "Couldn't set Publisher as the network's own landing-site theme — set it by hand in Appearance -> Themes."
}
install_pressbooks() {
require_docker || return 1
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ PRESSBOOKS │"
echo "│ Self-hosted book platform — write/import chapters with │"
echo "│ drag-and-drop images, export to PDF (print) and EPUB (ebook) │"
echo "└─────────────────────────────────────────────────────────────────┘"
echo ""
local DIR="$DOCKER_DIR/pressbooks"
local CONTAINER="pressbooks"
local DB_CONTAINER="pressbooks-db"
local WP_NET="pressbooks_net"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR — a dedicated WordPress Multisite install (never an"
echo "[DRY-RUN] existing site — Pressbooks requires a fresh multisite network)"
echo "[DRY-RUN] Would build a custom image on wordpress:php8.3-apache: mod_rewrite +"
echo "[DRY-RUN] AllowOverride All (multisite needs working .htaccess rewrites),"
echo "[DRY-RUN] Ghostscript/ImageMagick/poppler-utils/libxml2-utils (cover generator +"
echo "[DRY-RUN] EPUB validation), and the ImageMagick PDF-coder policy fix Debian ships"
echo "[DRY-RUN] disabled by default"
echo "[DRY-RUN] Would prompt for a PDF export engine: PrinceXML (installed on this"
echo "[DRY-RUN] container, free for non-commercial use) and/or DocRaptor (paid SaaS API key)"
echo "[DRY-RUN] Would auto-scan for a free host port (8095 default) and dedicated MariaDB"
echo "[DRY-RUN] Would run wp-cli non-interactively: core install, convert to Multisite"
echo "[DRY-RUN] (subdirectory network), install+network-activate the Pressbooks plugin"
echo "[DRY-RUN] and its three themes"
echo "[DRY-RUN] Would offer a Caddy reverse proxy gated by Authelia SSO, and to start it"
return 0
fi
# ── Existing install? Offer update-in-place ──────────────────────────────
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing Pressbooks' base image, cover-generator packages, and the"
log_info "Pressbooks plugin/themes only — books, domain, and credentials are left"
log_info "exactly as they are."
( cd "$DIR" && docker compose build --pull && docker compose up -d )
local _WP_PORT
_WP_PORT="$(grep '^WEB_PORT=' "$DIR/.env" | cut -d= -f2-)"
_pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "${_WP_PORT:-8095}"
log_success "Pressbooks refreshed"
return 0
;;
cancel)
log_info "Leaving the existing Pressbooks install as-is."
return 0
;;
fresh) ;;
esac
fi
# ── Network title + admin account ────────────────────────────────────────
local PB_TITLE="" PB_ADMIN_USER="" PB_ADMIN_EMAIL=""
prompt_text "Book network title (shown on the landing site):" "My Book Library" PB_TITLE
prompt_text "Admin username:" "admin" PB_ADMIN_USER
prompt_text "Admin email:" "" PB_ADMIN_EMAIL
local PB_ADMIN_PASS=""
[ -f "$DIR/.env" ] && PB_ADMIN_PASS="$(grep '^WP_ADMIN_PASSWORD=' "$DIR/.env" | cut -d= -f2-)"
[ -n "$PB_ADMIN_PASS" ] || PB_ADMIN_PASS="$(generate_password 16)"
# ── PDF export engine ─────────────────────────────────────────────────────
echo ""
echo " PDF export needs a rendering engine Pressbooks itself doesn't ship."
local INSTALL_PRINCE="" PRINCE_LICENSE_PATH="" USE_DOCRAPTOR="" DOCRAPTOR_KEY=""
prompt_yn "Install PrinceXML for PDF export? Free for personal use, adds a small logo unless licensed (y/n):" "y" INSTALL_PRINCE
if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then
prompt_text " Already have a paid PrinceXML license file (removes the logo)? Path, or blank to skip:" "" PRINCE_LICENSE_PATH
if [ -n "$PRINCE_LICENSE_PATH" ] && [ ! -f "$PRINCE_LICENSE_PATH" ]; then
log_warning " $PRINCE_LICENSE_PATH not found — continuing with the free non-commercial version."
PRINCE_LICENSE_PATH=""
fi
fi
prompt_yn "Also configure DocRaptor (SaaS alternative — needs your own API key, paid past a small free quota)? (y/n):" "n" USE_DOCRAPTOR
if [[ "$USE_DOCRAPTOR" =~ ^[Yy]$ ]]; then
prompt_text " DocRaptor API key (from https://docraptor.com/documentation/api):" "" DOCRAPTOR_KEY
fi
if [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ]; then
log_warning "No PDF engine configured — Pressbooks' PDF export will fail until PrinceXML"
log_warning "or DocRaptor is set up (re-run this installer to add one later)."
fi
# ── Free host port ────────────────────────────────────────────────────────
local WEB_PORT=8095
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$DIR/html" "$DIR/db" "$DIR/uploads-ini.d"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
local TZ_VAL="${SITE_TZ:-UTC}"
# Book covers, full-book PDF/EPUB exports, and large chapter-image
# imports all run well past stock PHP limits — sized generously up front
# rather than waiting for a first export to hit a wall.
cat > uploads-ini.d/uploads.ini << 'PHPINI'
file_uploads = On
memory_limit = 512M
upload_max_filesize = 128M
post_max_size = 128M
max_execution_time = 600
max_input_time = 600
PHPINI
# WordPress core's own is_ssl() only looks at $_SERVER['HTTPS'], never
# X-Forwarded-Proto — behind Caddy (which terminates TLS and proxies
# plain HTTP to this container) that reads as "never HTTPS," sending
# wp-admin into a login/redirect loop the moment Caddy is wired up.
# This file is `require`d from wp-config.php via WORDPRESS_CONFIG_EXTRA
# below rather than passing the PHP itself through that env var: Compose
# interpolates $VAR-looking tokens found INSIDE .env file values, not
# just inside docker-compose.yml — confirmed live, a raw $_SERVER
# sitting in .env gets silently blanked to a bare "_SERVER" (with a
# "variable is not set" warning) before the container ever sees it, and
# escaping it as $$_SERVER doesn't collapse back to a single $ for an
# env_file-sourced value the way it does for a plain environment: entry
# — it would reach PHP as a broken "$$_SERVER" variable-variable
# instead. A bind-mounted file sidesteps the whole thing: nothing in it
# ever passes through Compose's .env parser.
cat > extra-config.php << 'PHPEXTRA'
<?php
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
PHPEXTRA
[[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo "define('PB_PRINCE_COMMAND', '/usr/local/bin/prince');" >> extra-config.php
[ -n "$DOCRAPTOR_KEY" ] && echo "define('DOCRAPTOR_API_KEY', '$DOCRAPTOR_KEY');" >> extra-config.php
# Prince license file, if provided, is bind-mounted rather than baked
# into the image — keeps a personal/purchased license out of the image
# layer, and survives an image rebuild on the "update" path untouched.
local PRINCE_LICENSE_VOLUME=""
if [ -n "$PRINCE_LICENSE_PATH" ]; then
cp "$PRINCE_LICENSE_PATH" "$DIR/prince-license.dat"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/prince-license.dat"
chmod 600 "$DIR/prince-license.dat"
PRINCE_LICENSE_VOLUME=" - ./prince-license.dat:/usr/local/lib/prince/license/license.dat:ro
"
fi
# ── Dockerfile ────────────────────────────────────────────────────────────
local _PRINCE_DOCKERFILE_BLOCK=""
if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then
_PRINCE_DOCKERFILE_BLOCK='
# PrinceXML — the PDF rendering engine Pressbooks shells out to for PDF
# export. Free for non-commercial use (small logo on page 1 of every PDF; a
# purchased license.dat, bind-mounted by docker-compose.yml, removes it).
# Uses the "linux-generic" tarball rather than a distro-pinned .deb/.rpm so
# this keeps working if wordpress:php8.3-apache'"'"'s underlying Debian release
# moves on, and resolves the current major version + exact filename at
# build time instead of hardcoding one that will eventually go stale.
RUN set -eux; \
ARCH="$(uname -m)"; \
MAJOR="$(curl -fsSL https://www.princexml.com/download/ | grep -oE "/download/[0-9]+/" | grep -oE "[0-9]+" | sort -n | tail -1)"; \
TARBALL_PATH="$(curl -fsSL "https://www.princexml.com/download/${MAJOR}/" | grep -oE "/download/prince-[0-9.]+-linux-generic-${ARCH}\.tar\.gz" | head -1)"; \
curl -fsSL "https://www.princexml.com${TARBALL_PATH}" -o /tmp/prince.tar.gz; \
mkdir -p /tmp/prince && tar -xzf /tmp/prince.tar.gz -C /tmp/prince --strip-components=1; \
printf "\n" | /tmp/prince/install.sh; \
rm -rf /tmp/prince /tmp/prince.tar.gz
'
fi
backup_if_exists Dockerfile
cat > Dockerfile << DOCKERFILE
FROM wordpress:php8.3-apache
# Multisite's subdirectory rewrite rules live in .htaccess — the base
# php-apache image ships mod_rewrite disabled and AllowOverride None, so
# .htaccess is silently ignored (pretty URLs 404, book pages don't route)
# without this.
RUN a2enmod rewrite \\
&& sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
# Pressbooks' cover generator shells out to Ghostscript/ImageMagick and
# poppler-utils (pdftoppm/pdfinfo) to rasterize book covers; libxml2-utils
# (xmllint) backs EPUB/HTMLBook validation. curl/ca-certificates are needed
# by the PrinceXML install step below, when enabled.
RUN apt-get update \\
&& apt-get install -y --no-install-recommends \\
ghostscript imagemagick poppler-utils libxml2-utils curl ca-certificates \\
&& rm -rf /var/lib/apt/lists/*
# Debian's ImageMagick ships a security policy (a CVE-2016-3714 mitigation)
# that blocks the PDF/PS/EPS coders by default. Without this, ImageMagick
# refuses to rasterize the PDF Ghostscript hands it for a cover thumbnail —
# fails with "not authorized \`PDF'" rather than producing an image.
RUN for f in /etc/ImageMagick-6/policy.xml /etc/ImageMagick-7/policy.xml; do \\
[ -f "\$f" ] && sed -i -E 's/rights="none" pattern="(PDF|PS|EPS)"/rights="read|write" pattern="\\1"/' "\$f"; \\
done; true
${_PRINCE_DOCKERFILE_BLOCK}
DOCKERFILE
# ── Caddy network wiring ──────────────────────────────────────────────────
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_LINE="" _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_LINE=" - caddy_net
"
_CADDY_NET_SECTION="
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
# ── docker-compose.yml ────────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PBCOMPOSE
name: pressbooks
services:
pressbooks:
build: .
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
depends_on:
- db
volumes:
- ./html:/var/www/html
- ./uploads-ini.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
- ./extra-config.php:/usr/local/etc/pressbooks/extra-config.php:ro
${PRINCE_LICENSE_VOLUME} ports:
- "${WEB_PORT}:80"
networks:
- default
${_CADDY_NET_LINE}
db:
image: mariadb:11
container_name: $DB_CONTAINER
hostname: $DB_CONTAINER
restart: unless-stopped
env_file: .env
volumes:
- ./db:/var/lib/mysql
networks:
- default
networks:
default:
name: $WP_NET
${_CADDY_NET_SECTION}
PBCOMPOSE
# ── .env ──────────────────────────────────────────────────────────────────
local WP_DB_PASS="" WP_DB_ROOT_PASS=""
[ -f ".env" ] && WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' .env | cut -d= -f2-)"
[ -f ".env" ] && WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' .env | cut -d= -f2-)"
[ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)"
[ -n "$WP_DB_ROOT_PASS" ] || WP_DB_ROOT_PASS="$(generate_password 32)"
# Loads the file written above — see the comment there for why the PHP
# itself doesn't live directly in this env var. This line has no "$" in
# it at all, so it can't run into Compose's .env interpolation.
local _CONFIG_EXTRA="require '/usr/local/etc/pressbooks/extra-config.php';"
backup_if_exists .env
cat > .env << PBENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
WEB_PORT=$WEB_PORT
# Dedicated MariaDB for this network alone.
MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS
MYSQL_DATABASE=pressbooks
MYSQL_USER=pressbooks
MYSQL_PASSWORD=$WP_DB_PASS
WORDPRESS_DB_HOST=$DB_CONTAINER
WORDPRESS_DB_NAME=pressbooks
WORDPRESS_DB_USER=pressbooks
WORDPRESS_DB_PASSWORD=$WP_DB_PASS
WORDPRESS_CONFIG_EXTRA="$_CONFIG_EXTRA"
# Only consulted by wp-cli during initial setup below, not read by the
# wordpress:apache image itself.
WP_SITE_TITLE=$PB_TITLE
WP_ADMIN_USER=$PB_ADMIN_USER
WP_ADMIN_PASSWORD=$PB_ADMIN_PASS
WP_ADMIN_EMAIL=$PB_ADMIN_EMAIL
PBENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
log_success "Pressbooks configured at $DIR (port $WEB_PORT)"
log_info "Building image (first build downloads PrinceXML and cover-generator packages — can take a few minutes)..."
if ! docker compose build && docker compose up -d; then
log_error "Failed to build/start — check: docker compose logs"
return 1
fi
log_info "Waiting for WordPress to come up..."
local _tries=0
until docker exec "$CONTAINER" curl -fs -o /dev/null http://localhost/ 2>/dev/null || [ "$_tries" -ge 30 ]; do
sleep 1; _tries=$((_tries + 1))
done
_wpcli() { docker run --rm --network "$WP_NET" -v "$DIR/html:/var/www/html" --env-file "$DIR/.env" wordpress:cli "$@"; }
log_info "Running wp-cli core install..."
if ! _wpcli core install \
--url="http://localhost:${WEB_PORT}" \
--title="$PB_TITLE" \
--admin_user="$PB_ADMIN_USER" \
--admin_password="$PB_ADMIN_PASS" \
--admin_email="$PB_ADMIN_EMAIL" \
--skip-email; then
log_error "wp-cli core install failed — WordPress may not have been ready yet. Retry manually:"
log_error " docker run --rm --network $WP_NET -v $DIR/html:/var/www/html --env-file $DIR/.env wordpress:cli core install ..."
return 1
fi
# Multisite refuses to activate with the "Plain" (query-string) permalink
# structure — pretty permalinks are a hard prerequisite, not optional.
log_info "Setting pretty permalinks and converting to a Multisite network..."
_wpcli rewrite structure '/%postname%/' --hard
_wpcli core multisite-convert --title="$PB_TITLE"
# multisite-convert doesn't rewrite .htaccess itself on Apache — without
# this, every site but the root 404s.
_wpcli rewrite flush --hard
_pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "$WEB_PORT"
# ── Authelia SSO gate ─────────────────────────────────────────────────────
# Pressbooks/WordPress has its own login screen, but no native
# OIDC/reverse-proxy-auth support the way gitea/mealie do (a third-party
# plugin could add one, the same "bigger lift" caveat CLAUDE.md notes for
# Jellyfin/Home Assistant) — so this is the same forward_auth gate used
# for services with no built-in auth at all: Authelia guards the front
# door, WordPress's own login is still a second gate behind it.
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local USE_AUTHELIA=""
prompt_yn "Protect Pressbooks with Authelia SSO? (y/n):" "y" USE_AUTHELIA
[[ "$USE_AUTHELIA" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "Pressbooks" "${CONTAINER}:80" "books" "$EXTRA_BLOCK"
# Reconcile the domain WordPress/Multisite think they're on: core install
# ran against http://localhost:$WEB_PORT since the final domain isn't
# known until the Caddy prompt above. Two passes — the full scheme+host
# string first (catches siteurl/home, stored with "http://"), then the
# bare host (catches wp_site.domain/wp_blogs.domain, stored without a
# scheme) — doing it in the other order would leave siteurl/home on
# "http://" instead of "https://" once Caddy is terminating TLS.
if [ "$CADDY_SERVICE_CONFIGURED" = true ] && [ -n "$CADDY_SERVICE_DOMAIN" ]; then
_wpcli search-replace "http://localhost:${WEB_PORT}" "https://${CADDY_SERVICE_DOMAIN}" --network --all-tables --report-changed-only
_wpcli search-replace "localhost:${WEB_PORT}" "$CADDY_SERVICE_DOMAIN" --network --all-tables --report-changed-only
log_success "Updated the network's URLs to https://$CADDY_SERVICE_DOMAIN"
fi
declare -F _authelia_scope_access >/dev/null 2>&1 && [ "$CADDY_SERVICE_CONFIGURED" = true ] \
&& _authelia_scope_access "pressbooks" "$CADDY_SERVICE_DOMAIN"
local PB_ACCESS_URL="http://localhost:${WEB_PORT}"
[ "$CADDY_SERVICE_CONFIGURED" = true ] && PB_ACCESS_URL="https://$CADDY_SERVICE_DOMAIN"
write_readme "$DIR" << MD
# Pressbooks
Self-hosted book platform on a dedicated WordPress Multisite network (its
own container/database — never shares an install with \`services/wordpress.sh\`,
since Pressbooks requires a fresh multisite network of its own).
- Network admin: ${PB_ACCESS_URL}/wp-admin/network/
- Admin user: \`$PB_ADMIN_USER\`
- Admin password: see \`WP_ADMIN_PASSWORD\` in \`.env\`
- Book files: \`html/\`
- Database files: \`db/\`
- PHP limits: \`uploads-ini.d/uploads.ini\` (512M memory, 128M uploads, 600s
execution time — a full-book PDF export can take a while)
## Creating a book
My Sites -> Network Admin -> Sites -> Add New creates a new book (its own
site in the network). Each book gets its own theme, its own chapters, and
its own front/back matter, picked from the Pressbooks admin bar once inside it.
## Writing and placing images
Chapters are written in WordPress's own block editor. Type directly into a
chapter; to place an image, either drag an image file straight into the
content area to drop it in as an Image block exactly where you dropped it,
or use the editor's own Add Media button, which also accepts drag-and-drop
in its upload dialog. Cover images are uploaded the same way from a book's
own Book Info screen.
## Exporting
Export options live under each book's own Export screen.
- **EPUB** — generated directly by Pressbooks, no extra engine needed.
- **PDF** — needs the rendering engine chosen at install time:
$( [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo " - PrinceXML is installed on this container.$( [ -n "$PRINCE_LICENSE_PATH" ] && echo " A license file is installed — no watermark." || echo " Free non-commercial version — adds a small logo to page 1 of every PDF; re-run this installer with a purchased license.dat to remove it." )" )
$( [ -n "$DOCRAPTOR_KEY" ] && echo " - DocRaptor is configured as an alternative/fallback (uses your own API key — real documents count against your DocRaptor plan; DocRaptor's own \`test\` mode produces unlimited watermarked previews for free)." )
$( [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ] && echo " - Not configured yet — re-run this installer (Update or Full reinstall) to add PrinceXML and/or DocRaptor." )
- **MOBI/Kindle** — Pressbooks removed MOBI export after Amazon discontinued
KindleGen and stopped accepting MOBI on KDP (March 2025). For a personal
Kindle copy, export EPUB and convert it with Calibre — this repo's own
\`calibre-web\` service can do that conversion if you don't already have
Calibre elsewhere.
## Manage
\`\`\`bash
cd $DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose build --pull && docker compose up -d # refresh base image + packages
\`\`\`
Or re-run \`sudo ./setup.sh pressbooks\` and choose Update, which also
refreshes the Pressbooks plugin/themes to their latest release.
## wp-cli
\`\`\`bash
docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\
--env-file $DIR/.env wordpress:cli wp <command>
\`\`\`
## Backup
\`services/backup.sh\` (Kopia) already covers this directory automatically —
generic for every \`~/docker/*\` directory with a \`docker-compose.yml\`, so
both \`html/\` (every book's content and media) and \`db/\` are captured
together on every run with no per-service setup needed.
MD
echo ""
echo " Access at: $PB_ACCESS_URL"
echo " Network admin: ${PB_ACCESS_URL}/wp-admin/network/"
echo " Admin user: $PB_ADMIN_USER"
echo " Admin pass: $PB_ADMIN_PASS"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_pressbooks