# Can I self-host Slab?

**YES** — it's called BookStack. ONE EVENING setup · ~1.5 hours to running · 1 GB RAM minimum · $33.35/mo you stop paying ($400.20/yr on the Startup plan, 5 seats assumed).

BookStack authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/slab/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install BookStack 26.05.3 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. `<DOMAIN>` becomes `APP_URL`, and BookStack builds every link it stores from that
value, so moving it later is a database edit. Its A record must already point here.
`<ADMIN_EMAIL>` is what the administrator signs in with; this install configures no mail, so
nothing is ever sent to it.

BookStack and its database need 1024 MB of RAM available and 5 GB free on /srv. Both images
publish amd64 and arm64. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name nobody resolves.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/bookstack /srv/bookstack/backups /srv/bookstack/config
sudo install -d -m 700 /srv/bookstack/mariadb
ls -la /srv/bookstack
```

Assert: `backups` and `config` owned by the login user, `mariadb` at mode `700` owned by root.
Leave that last one alone; the MariaDB image chowns its own data directory and refuses one
somebody claimed first. `config` is the other half of the wiki: uploaded images, attachments
and themes.

## 3. Secrets

Four secrets, generated here: the application key, the database password, the MariaDB root
password, and the administrator's password. Print none, and keep all four out of your summary
and every log line.

```bash
umask 077
cat > /srv/bookstack/.env <<EOF
APP_URL=https://<DOMAIN>
ADMIN_EMAIL=<ADMIN_EMAIL>
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
printf 'PUID=%s\nPGID=%s\n' "$(id -u)" "$(id -g)" >> /srv/bookstack/.env
chmod 600 /srv/bookstack/.env
umask 022
ls -l /srv/bookstack/.env
```

Assert: mode `-rw-------` and the login user's name twice. `APP_KEY` is 32 random bytes in the
`base64:` form BookStack's key generator emits, which is why openssl makes it here rather than
a helper that pulls an unpinned image. Tell the user, without printing anything, that this file
is now the most valuable object on the box: everything BookStack encrypts at rest uses it.

## 4. compose.yml

```bash
cat > /srv/bookstack/compose.yml <<'EOF'
# BookStack · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ... https://www.bookstackapp.com/docs/admin/installation/
#   configuration .. https://github.com/BookStackApp/BookStack/blob/v26.05.3/.env.example.complete
#   image docs ..... https://docs.linuxserver.io/images/docker-bookstack/
#
# BookStack ships no Docker image of its own; its installation page points at
# community docker setups. This file uses the LinuxServer.io one, GPL-3.0,
# which unpacks BookStack's own 26.05.3 release archive onto their Alpine plus
# nginx base image. The application is upstream's, the packaging is not.
#
# Two services: BookStack and the MariaDB holding every shelf, book, chapter
# and page. Every ${...} comes from /srv/bookstack/.env, mode 600, which
# Compose reads and never mounts. Digests read 2026-08-06; both are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: bookstack-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: bookstack
      MARIADB_USER: bookstack
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/bookstack/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  bookstack:
    image: lscr.io/linuxserver/bookstack:version-v26.05.3@sha256:7f0af07baa41fd6265f5ec57887564d85be03a326f79cb32f926fe735e5313ff
    container_name: bookstack
    restart: unless-stopped
    environment:
      # The image's own `abc` user is remapped to these, so config/ is yours.
      PUID: "${PUID}"
      PGID: "${PGID}"
      TZ: Etc/UTC
      # Every URL BookStack builds comes from this one value, so it carries the
      # https Caddy terminates rather than the plain http the container speaks.
      APP_URL: ${APP_URL}
      # Session and at-rest key. The image halts its init without one.
      APP_KEY: ${APP_KEY}
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: bookstack
      DB_USERNAME: bookstack
      DB_PASSWORD: ${DB_PASSWORD}
      # Caddy terminates TLS. Upstream ships this false by default.
      SESSION_SECURE_COOKIE: "true"
      # Trust the proxy: the audit log then names the reader, not Caddy.
      APP_PROXIES: "*"
      # Neither is a BookStack setting; the application ignores both. They
      # let step 7 hand them to the console command that replaces the account
      # the first migration seeds, without either value reaching
      # a command line or the host's process list.
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
    volumes:
      - /srv/bookstack/config:/config
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/status || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: Caddy alone reaches 8150; the container listens on 80.
      - "127.0.0.1:8150:80"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/bookstack && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No database port is published and no credential is written
here; the account that arrives with a published password is closed in step 7.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy it first: a syntax error takes down every other site here.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-bookstack
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# BookStack · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linuxserver.io/images/docker-bookstack/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also APP_URL in .env, and BookStack
# builds every link it stores from APP_URL, so moving it is a database edit.

<DOMAIN> {
	encode zstd gzip

	# BookStack sets its own content-security and frame-ancestors headers.
	# These four are the ones a reverse proxy is the right place for.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8150 is the loopback port compose publishes here, not a container
	# port and not open in the firewall. Caddy sets X-Forwarded-For, and
	# APP_PROXIES in compose.yml lets BookStack read it.
	reverse_proxy 127.0.0.1:8150
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-bookstack, reload,
and report the objection. Caddy gets the certificate on the first request and renews it.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8150 is bound to 127.0.0.1 and 3306 is never published, so neither has a host port a
rule could apply to. Assert: `Status: active`, 80, 443/tcp and 443/udp, nothing else.

## 7. Start and verify

Read this first. BookStack's first migration inserts an administrator, `admin@admin.com` with
the password `password`, and the image's install notes publish that pair. Until the command
below runs it is a known credential on a hostname that already resolves, so run the whole block
in one go.

```bash
cd /srv/bookstack
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/status
docker compose exec -T --user abc bookstack sh -c 'php /app/www/artisan bookstack:create-admin --initial --no-ansi --name="Site administrator" --email="$ADMIN_EMAIL" --password="$ADMIN_PASSWORD"'
curl -sS https://<DOMAIN>/login | grep -c 'list-heading">Log In<'
```

Assert all four, printing what you received for each. The loop ends on `200`. The status
response is `{"database":true,"cache":true,"session":true}`. The console command prints
`The default admin user has been updated with the provided details!`, which is how you know it
rewrote the seeded account rather than adding a second administrator. The last one prints `1`.

Now prove the published credential is dead. Server or the user's machine:

```bash
shipped=password
jar=$(mktemp)
tok=$(curl -sS -c "$jar" https://<DOMAIN>/login | sed -n 's/.*name="_token" value="\([^"]*\)".*/\1/p' | head -1)
echo "csrf token length ${#tok}"
curl -sS -b "$jar" -c "$jar" -L -d "_token=$tok" -d "email=admin@admin.com" -d "password=$shipped" https://<DOMAIN>/login | grep -c 'These credentials do not match our records'
rm -f "$jar"
unset shipped
```

Assert: the token length is not `0` and the last line prints `1`. That is BookStack's own
wording for a rejected sign-in, and it is the security assert here. A zero-length token means
the attempt failed for a missing CSRF token rather than a wrong password, so treat it as a
failure too. If the count is `0` the old pair still works: stop, say so, and do not report
success. If any of the earlier four missed, stop, run `docker compose logs --tail 60 bookstack`
and `docker compose logs --tail 20 db`, and name the likely step: a database that never reports
healthy is step 2, a lasting `502` is step 5. A running container is not success.

The first screen at https://<DOMAIN>/login shows the heading `Log In` above an `Email` field, a
`Password` field and a `Log In` button.

STOP: tell the user to read their password with
`sudo grep ADMIN_PASSWORD /srv/bookstack/.env`, put it in their password manager, sign in at
https://<DOMAIN>/login with `<ADMIN_EMAIL>`, and wait. Do not continue until they confirm they
are on the empty shelves page. There is no password-reset mail here, so that entry is the only
copy.

## 8. First backup and restore

Two artifacts. The dump holds every shelf, book, chapter, page and revision. The config archive
holds the uploaded images and attachments plus the three files that rebuild the service around
them, including the key nothing encrypted in the dump comes back without.

```bash
cd /srv/bookstack
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/bookstack/backups/bookstack-db-$(date +%F).sql.gz
sudo tar -czf /srv/bookstack/backups/bookstack-config-$(date +%F).tar.gz -C /srv/bookstack compose.yml .env config -C /etc/caddy Caddyfile
ls -lh /srv/bookstack/backups/
```

Assert: both exist, both are non-empty, both sizes printed. Nothing goes offline: the tables
are InnoDB, so the dump snapshots consistently while the wiki serves.

A backup on the same disk is not a backup. Run this one from the user's machine, not the
server:

```bash
mkdir -p ~/backups/bookstack
scp vps:/srv/bookstack/backups/* ~/backups/bookstack/
```

To restore: `docker compose down`, `sudo rm -rf /srv/bookstack/mariadb /srv/bookstack/config`,
recreate both as step 2 does, untar the config archive into /srv/bookstack so `.env` and
`config` are back before anything starts, `docker compose up -d db`, wait 30 seconds for
healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d`. Tell the user why `.env` comes back first: MariaDB reads its
password from it when it initialises an empty directory, and its `APP_KEY` is the only key
that decrypts the dump.

## 9. Updating later

Versions are listed at https://github.com/BookStackApp/BookStack/releases and the image tags
carrying them at https://github.com/linuxserver/docker-bookstack/tags. Take both backups first,
then edit the bookstack image line in compose.yml to the new tag and digest:

```bash
cd /srv/bookstack
docker compose pull
docker compose up -d
docker compose logs --tail 40 bookstack
```

The image runs the schema migration on every start, so a version bump migrates the database
itself. Watch that log until it settles, then re-run step 7's status check.

## 10. What will probably go wrong

The container will sit there `Up`, answer nothing, and `docker ps` will tell you everything is
fine. Mine did, for four minutes, before I read the log. The image checks for an application
key before anything else, and when it finds none it prints
`The application key is missing, halting init!` and then sleeps forever instead of exiting, so
the container never restarts and never looks broken. If step 7's loop stays on `502` or `000`,
run `docker compose logs --tail 40 bookstack` and look for that line first: `APP_KEY` did not
reach the container, which is step 3 or a `docker compose` run from the wrong directory.

## 11. Out of scope

- Do not configure SMTP. The wiki works without it; mail buys password resets, invitations and
  page-watch notifications, and that is a second install to do properly.
- Do not enable public registration. It is off in BookStack's defaults, and turning it on puts
  a sign-up form on a public hostname.
- Do not configure LDAP, SAML or OIDC. Those replace the account step 7 secured, and one
  half-configured locks the user out of their own wiki.
- Do not switch storage to S3 or turn on the queue worker. Local files and synchronous jobs
  are the choice here, and both add a part this prompt does not back up.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing BookStack 26.05.3 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box, and `<ADMIN_EMAIL>` with the address you want to sign in as.

Read this before step 1. `<DOMAIN>` becomes `APP_URL`, and BookStack builds every link it
stores and every link it prints from that one value. Changing it after you have written pages
is not a config edit, it is a command that rewrites URLs across the database. Pick the
hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a
minute, run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that
does not resolve, and failed attempts count against a rate limit you cannot see. An IP that is
not your server's usually means a proxying CDN sits in front of the record; turn that off for
this hostname while you install, because the certificate would otherwise be issued to
somebody else's edge. Under 1024 MB free is the one you should not argue with: PHP-FPM and
MariaDB in the same box is where a 1 GB VPS starts swapping.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/bookstack /srv/bookstack/backups /srv/bookstack/config
sudo install -d -m 700 /srv/bookstack/mariadb
ls -la /srv/bookstack
```

You should see: `backups` and `config` owned by you, and `mariadb` at mode `drwx------` owned
by root.

If you do not: leave `mariadb` owned by root on purpose. The MariaDB image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. `config` is the other half of the wiki and it does belong to you: every
image someone pastes into a page, every file attachment and every theme lands under it.

## 3. Secrets

Four secrets: the application key, the database password, the MariaDB root password, and the
administrator's password. All four are generated here, on the server, and go straight into a
file only you can read. The last two lines add your own uid and gid, which the image uses to
keep `config` yours rather than adopting it as uid 911.

```bash
umask 077
cat > /srv/bookstack/.env <<EOF
APP_URL=https://<DOMAIN>
ADMIN_EMAIL=<ADMIN_EMAIL>
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
printf 'PUID=%s\nPGID=%s\n' "$(id -u)" "$(id -g)" >> /srv/bookstack/.env
chmod 600 /srv/bookstack/.env
umask 022
ls -l /srv/bookstack/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>`
and `<ADMIN_EMAIL>` on the first two lines with your real values before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/bookstack/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
all four values, which is fine before the database exists and a problem afterwards: MariaDB
keeps the password it was created with, and BookStack cannot decrypt old data under a new
`APP_KEY`.

Do not paste that file, any of those four values, or any command output containing them into
this chat window. The agent path never sees them; this path will hand them to a third party
unless you keep them out. Read your own password later with
`sudo grep ADMIN_PASSWORD /srv/bookstack/.env` in a terminal, not here. One more place two of
them live: compose.yml hands `ADMIN_EMAIL` and `ADMIN_PASSWORD` to the container environment
for step 7's command, where `docker inspect bookstack` can read them afterwards, the same
boundary as the file itself on a box where the docker group is root-equivalent.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/bookstack/compose.yml <<'EOF'
# BookStack · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ... https://www.bookstackapp.com/docs/admin/installation/
#   configuration .. https://github.com/BookStackApp/BookStack/blob/v26.05.3/.env.example.complete
#   image docs ..... https://docs.linuxserver.io/images/docker-bookstack/
#
# BookStack ships no Docker image of its own; its installation page points at
# community docker setups. This file uses the LinuxServer.io one, GPL-3.0,
# which unpacks BookStack's own 26.05.3 release archive onto their Alpine plus
# nginx base image. The application is upstream's, the packaging is not.
#
# Two services: BookStack and the MariaDB holding every shelf, book, chapter
# and page. Every ${...} comes from /srv/bookstack/.env, mode 600, which
# Compose reads and never mounts. Digests read 2026-08-06; both are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: bookstack-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: bookstack
      MARIADB_USER: bookstack
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/bookstack/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  bookstack:
    image: lscr.io/linuxserver/bookstack:version-v26.05.3@sha256:7f0af07baa41fd6265f5ec57887564d85be03a326f79cb32f926fe735e5313ff
    container_name: bookstack
    restart: unless-stopped
    environment:
      # The image's own `abc` user is remapped to these, so config/ is yours.
      PUID: "${PUID}"
      PGID: "${PGID}"
      TZ: Etc/UTC
      # Every URL BookStack builds comes from this one value, so it carries the
      # https Caddy terminates rather than the plain http the container speaks.
      APP_URL: ${APP_URL}
      # Session and at-rest key. The image halts its init without one.
      APP_KEY: ${APP_KEY}
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: bookstack
      DB_USERNAME: bookstack
      DB_PASSWORD: ${DB_PASSWORD}
      # Caddy terminates TLS. Upstream ships this false by default.
      SESSION_SECURE_COOKIE: "true"
      # Trust the proxy: the audit log then names the reader, not Caddy.
      APP_PROXIES: "*"
      # Neither is a BookStack setting; the application ignores both. They
      # let step 7 hand them to the console command that replaces the account
      # the first migration seeds, without either value reaching
      # a command line or the host's process list.
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
    volumes:
      - /srv/bookstack/config:/config
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/status || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: Caddy alone reaches 8150; the container listens on 80.
      - "127.0.0.1:8150:80"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/bookstack && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/bookstack/compose.yml` and paste again in one go. A warning that
`PUID` or `APP_KEY` is not set means you are not in /srv/bookstack, or step 3 did not write the
file; Compose reads `.env` from the directory you run it in, which is why every command in this
prompt starts with `cd /srv/bookstack`.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-bookstack
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# BookStack · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linuxserver.io/images/docker-bookstack/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also APP_URL in .env, and BookStack
# builds every link it stores from APP_URL, so moving it is a database edit.

<DOMAIN> {
	encode zstd gzip

	# BookStack sets its own content-security and frame-ancestors headers.
	# These four are the ones a reverse proxy is the right place for.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8150 is the loopback port compose publishes here, not a container
	# port and not open in the firewall. Caddy sets X-Forwarded-For, and
	# APP_PROXIES in compose.yml lets BookStack read it.
	reverse_proxy 127.0.0.1:8150
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-bookstack /etc/caddy/Caddyfile`,
reload, and paste again. The usual cause is a `<DOMAIN>` you forgot to replace, which Caddy
reads as a site name containing angle brackets. Caddy requests the certificate on the first
request that arrives for the hostname and renews it on its own, so there is nothing to
schedule and nothing to renew by hand.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8150` or `3306`.

If you do not: delete anything for `8150` or `3306` with `sudo ufw delete allow 8150`. 8150 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and to answer
the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back before you go further.

## 7. Start and verify

Read this before you paste. BookStack's first database migration inserts an administrator with
the email `admin@admin.com` and the password `password`, and the image's install notes publish
that pair. From the moment the schema exists until the console command below runs, that is a
known credential on a hostname that already resolves. Run this block in one sitting.

```bash
cd /srv/bookstack
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/status
docker compose exec -T --user abc bookstack sh -c 'php /app/www/artisan bookstack:create-admin --initial --no-ansi --name="Site administrator" --email="$ADMIN_EMAIL" --password="$ADMIN_PASSWORD"'
curl -sS https://<DOMAIN>/login | grep -c 'list-heading">Log In<'
```

You should see, in order: the loop climbing to `200`, then
`{"database":true,"cache":true,"session":true}`, then
`The default admin user has been updated with the provided details!`, then `1`.

If you do not: the console line is the one to read carefully. That exact sentence means the
command found the seeded `admin@admin.com` account and rewrote its name, email and password in
place. `Admin account with email ... successfully created!` instead means it did not find that
account and made a second administrator, which leaves the first one alone and is not what you
want; stop and check whether an earlier attempt already changed it. If the loop never reaches
`200`, run `docker compose logs --tail 20 db` first, because a database that never reports
healthy is step 2 done wrong, and `docker compose logs --tail 60 bookstack` second. A `502`
that never clears means Caddy is reaching nothing on 8150.

Now prove the published credential is dead. This works from the server or from your own
machine:

```bash
shipped=password
jar=$(mktemp)
tok=$(curl -sS -c "$jar" https://<DOMAIN>/login | sed -n 's/.*name="_token" value="\([^"]*\)".*/\1/p' | head -1)
echo "csrf token length ${#tok}"
curl -sS -b "$jar" -c "$jar" -L -d "_token=$tok" -d "email=admin@admin.com" -d "password=$shipped" https://<DOMAIN>/login | grep -c 'These credentials do not match our records'
rm -f "$jar"
unset shipped
```

You should see: a token length that is not `0`, then `1`.

If you do not: `1` means BookStack rejected the old pair with its own wording for a bad
sign-in, and that is the check that decides whether this install is safe to leave running. A
`0` means either the login succeeded, which is the bad case, or the request never got as far as
the password check. A token length of `0` tells you which: it means the page did not hand you a
CSRF token, so the attempt proved nothing and you should run the block again. If the token was
real and the count is `0`, the shipped password still works. Stop there, do not put anything in
the wiki, and re-run the console command from the previous block.

The first screen at https://<DOMAIN>/login shows the heading `Log In` above an `Email` field, a
`Password` field and a `Log In` button. A running container is not success; those two asserts
are.

Read your password once with `sudo grep ADMIN_PASSWORD /srv/bookstack/.env` and put it in your
password manager, then sign in at https://<DOMAIN>/login with the address you used for
`<ADMIN_EMAIL>`. There is no password-reset mail on this install, so that password manager
entry is the only copy you have.

## 8. First backup and restore

Two artifacts. The dump holds every shelf, book, chapter, page and revision. The config archive
holds the uploaded images and attachments plus the three files that rebuild the service around
them, including the application key without which nothing encrypted in the dump comes back.

```bash
cd /srv/bookstack
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/bookstack/backups/bookstack-db-$(date +%F).sql.gz
sudo tar -czf /srv/bookstack/backups/bookstack-config-$(date +%F).tar.gz -C /srv/bookstack compose.yml .env config -C /etc/caddy Caddyfile
ls -lh /srv/bookstack/backups/
```

You should see: two files, the dump a few tens of kilobytes on a fresh install and the archive
a little larger. Nothing goes offline while this runs.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump`
failed and the shell created the file anyway. Run the dump line without `| gzip` to read the
error; the usual one is that the database container is not up.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/bookstack
scp vps:/srv/bookstack/backups/* ~/backups/bookstack/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/bookstack/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.

Now prove the restore, today, while the only thing at risk is an empty wiki:

```bash
cd /srv/bookstack
docker compose down
sudo rm -rf /srv/bookstack/mariadb
sudo install -d -m 700 /srv/bookstack/mariadb
docker compose up -d db
sleep 40
gunzip -c /srv/bookstack/backups/bookstack-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/status
```

You should see: no output from the `gunzip` pipeline, then
`{"database":true,"cache":true,"session":true}` from the last command.

If you do not: `Access denied for user` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. If you ever restore onto a
different machine, put `.env` back before you start anything, because MariaDB reads
`DB_PASSWORD` from it the moment it initialises an empty directory and the `APP_KEY` in it is
the only key that decrypts what the dump carries.

## 9. Updating later

Application versions are listed at https://github.com/BookStackApp/BookStack/releases and the
image tags carrying them at https://github.com/linuxserver/docker-bookstack/tags. Take both
backups first, then edit the bookstack `image:` line in /srv/bookstack/compose.yml to the new
tag and its digest.

```bash
cd /srv/bookstack
docker compose pull
docker compose up -d
docker compose logs --tail 40 bookstack
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run
the status check from step 7 before you call the update done. The image runs the schema
migration on every start, so a version bump migrates the database on its own; what that also
means is that rolling back to an older image after a migration has run is not something the
database will forgive, which is why the backup comes first.

## 10. What will probably go wrong

The container will sit there `Up` and answer nothing, and `docker ps` will tell you everything
is fine. Mine did, for four minutes, before I read the log. The image checks for an application
key before anything else, and when it finds none it prints
`The application key is missing, halting init!` and then sleeps forever instead of exiting, so
the container never restarts and never looks broken. If step 7's loop stays on `502` or `000`,
run `docker compose logs --tail 40 bookstack` and look for that line first: `APP_KEY` did not
reach the container, which is step 3 or a `docker compose` run from the wrong directory.

## 11. Out of scope

- Do not configure SMTP. The wiki works without it; mail buys password resets, invitations and
  page-watch notifications, and that is a second install to do properly.
- Do not enable public registration. It is off in BookStack's defaults, and turning it on puts
  a sign-up form on a public hostname.
- Do not configure LDAP, SAML or OIDC. Those replace the account step 7 secured, and one
  half-configured locks you out of your own wiki.
- Do not switch storage to S3 or turn on the queue worker. Local files and synchronous jobs
  are the choice here, and both add a part this prompt does not back up.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install BookStack 26.05.3, with the MariaDB it keeps every page in, under ~/selfhost/bookstack,
answering at http://localhost:8150.

## 1. Preflight

Say this to the user before step 2; it decides whether they want this install. BookStack is a
team wiki, and this one answers at http://localhost:8150, which means "this computer" wherever
it is read: a colleague sent that link gets an error, and so does the user's own phone.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
ID and codename print next, for step 2. BookStack plus MariaDB needs 1024 MB of RAM available
and 5 GB free on the home disk, and both images publish amd64 and arm64. On macOS and Windows
that memory figure is the host's, minus Docker Desktop's VM. Under either floor, stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/bookstack/config ~/selfhost/bookstack/backups
ls -la ~/selfhost/bookstack
```

Assert: `config` and `backups`, owned by the user. `config` holds uploaded images, attachments
and themes; the pages are rows in MariaDB.

## 4. Secrets

Four secrets, generated here: the application key, the database password, the MariaDB root
password, and the administrator's password. Print none, and keep all four out of your summary
and every log line.

```bash
umask 077
cat > ~/selfhost/bookstack/.env <<EOF
APP_URL=http://localhost:8150
ADMIN_EMAIL=you@example.com
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
printf 'PUID=%s\nPGID=%s\n' "$(id -u)" "$(id -g)" >> ~/selfhost/bookstack/.env
chmod 600 ~/selfhost/bookstack/.env
umask 022
ls -l ~/selfhost/bookstack/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these run the same on all three.
`APP_KEY` encrypts whatever BookStack stores encrypted, so this file is the most valuable thing
under ~/selfhost. `ADMIN_EMAIL` is only a sign-in name: ask the user once and put their answer
in this file. On Windows the mode bits are advisory; the boundary is their own account.

## 5. compose.yml

```bash
cat > ~/selfhost/bookstack/compose.yml <<'EOF'
# BookStack · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installation ... https://www.bookstackapp.com/docs/admin/installation/
#   configuration .. https://github.com/BookStackApp/BookStack/blob/v26.05.3/.env.example.complete
#   image docs ..... https://docs.linuxserver.io/images/docker-bookstack/
#
# BookStack ships no Docker image of its own; its installation page points at
# community docker setups. This uses the LinuxServer.io one, GPL-3.0, unpacking
# BookStack's own 26.05.3 release archive onto their Alpine and nginx base
# image. The application is upstream's, the packaging is not.
#
# Paths are relative to ~/selfhost/bookstack/, so one file works on all three
# systems. The database is a named volume because MariaDB chowns its data
# directory to a uid Docker Desktop cannot grant on a Windows bind mount;
# config/ is a bind mount, owned by PUID/PGID. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: bookstack-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: bookstack
      MARIADB_USER: bookstack
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - bookstack-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  bookstack:
    image: lscr.io/linuxserver/bookstack:version-v26.05.3@sha256:7f0af07baa41fd6265f5ec57887564d85be03a326f79cb32f926fe735e5313ff
    container_name: bookstack
    restart: unless-stopped
    environment:
      PUID: "${PUID}"
      PGID: "${PGID}"
      TZ: Etc/UTC
      APP_URL: ${APP_URL}
      # Session and at-rest key. The image halts its init without one.
      APP_KEY: ${APP_KEY}
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: bookstack
      DB_USERNAME: bookstack
      DB_PASSWORD: ${DB_PASSWORD}
      # Neither is a BookStack setting. They let step 7 hand them to the
      # command that replaces the seeded account, off the command line.
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
    volumes:
      - ./config:/config
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/status || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi reaches 8150.
      - "127.0.0.1:8150:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  bookstack-dbdata:
EOF
cd ~/selfhost/bookstack && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule: no hostname to resolve, no public name for
a certificate to attest, nothing published beyond loopback to close. Browsers treat
http://localhost as a secure context anyway, so pages needing crypto still work.

8150 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not anyone on the
internet. That is the point of this path, not a gap in it. Confirm:

```bash
grep -c '"127.0.0.1:' ~/selfhost/bookstack/compose.yml
```

Assert: `1`, the published-port line. MariaDB publishes no host port, so 3306 cannot appear.

## 7. Start and verify

Read this first. BookStack's first migration seeds `admin@admin.com` with the password
`password`, a pair the image's install notes publish. That account is real, so run this in one
go.

```bash
cd ~/selfhost/bookstack
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8150/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8150/status
docker compose exec -T --user abc bookstack sh -c 'php /app/www/artisan bookstack:create-admin --initial --no-ansi --name="Site administrator" --email="$ADMIN_EMAIL" --password="$ADMIN_PASSWORD"'
curl -sS http://localhost:8150/login | grep -c 'list-heading">Log In<'
```

Assert all four, printing what you got. The loop ends on `200`. Status is
`{"database":true,"cache":true,"session":true}`. The console prints
`The default admin user has been updated with the provided details!`, proof it rewrote the
seeded account rather than adding a second one. The last prints `1`.

Now prove the published credential is dead:

```bash
shipped=password
jar=$(mktemp)
tok=$(curl -sS -c "$jar" http://localhost:8150/login | sed -n 's/.*name="_token" value="\([^"]*\)".*/\1/p' | head -1)
echo "csrf token length ${#tok}"
curl -sS -b "$jar" -c "$jar" -L -d "_token=$tok" -d "email=admin@admin.com" -d "password=$shipped" http://localhost:8150/login | grep -c 'These credentials do not match our records'
rm -f "$jar"
unset shipped
```

Assert: the token length is not `0` and the last line prints `1`, BookStack's wording for a
rejected sign-in and the security assert here. A zero-length token means a missing token, not a
wrong password, so treat it as a failure too. A `0` count means the old pair still works: stop,
and do not report success. On any other miss run `docker compose logs --tail 60 bookstack`: a
database never reporting healthy is step 4, `port is already allocated` is step 10.
A running container is not success.

The first screen at http://localhost:8150/login shows the heading `Log In` above an `Email`
field, a `Password` field and a `Log In` button.

STOP: tell the user to read their password with
`grep ADMIN_PASSWORD ~/selfhost/bookstack/.env`, put it in their password manager, sign in at
http://localhost:8150/login as the address in `ADMIN_EMAIL`, and wait.
Do not continue until they confirm the empty shelves page. Nothing sends reset mail, so that
is the only copy.

## 8. First backup and restore

Two artifacts: a dump holding every shelf, book, chapter, page and revision, and an archive of
the uploads plus the two files that rebuild the service around them, plus `APP_KEY`.

```bash
cd ~/selfhost/bookstack
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > ~/selfhost/bookstack/backups/bookstack-db-$(date +%F).sql.gz
tar -C ~/selfhost/bookstack -czf ~/selfhost/bookstack/backups/bookstack-config-$(date +%F).tar.gz compose.yml .env config
ls -lh ~/selfhost/bookstack/backups/
```

Assert: both exist, both non-empty, both sizes printed. Nothing goes offline: the tables are
InnoDB, so the dump snapshots consistently while the wiki keeps serving.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or a USB stick, and copy both there with `cp`; in Git Bash a Windows drive is `/d/...`.
Assert: both filenames are listed there, or this has no backup.

To restore: untar the config archive into ~/selfhost/bookstack first, so `.env` and `config`
are back before any container starts. MariaDB reads `DB_PASSWORD` from `.env` when it
initialises an empty volume, and `APP_KEY` is the only key that decrypts the dump. Then
`docker compose down -v`, the one place `-v` belongs, `docker compose up -d db`, wait 30s, pipe
`gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d`, sign in, open a page.

## 9. Updating later

Versions are listed at https://github.com/BookStackApp/BookStack/releases and the image tags at
https://github.com/linuxserver/docker-bookstack/tags. Back up first, then edit the image line
in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/bookstack
docker compose pull
docker compose up -d
docker compose logs --tail 40 bookstack
```

The image migrates the schema on every start, so a version bump migrates the database itself.
Watch the log until it settles, then re-run step 7's check.

## 10. What will probably go wrong

Something else already owns 8150. Mine was a dev server I had forgotten about, and
`docker compose up -d` answered `port is already allocated`, which reads like a Docker fault
rather than a neighbour. Find what holds it (`lsof -nP -iTCP:8150 -sTCP:LISTEN`, or
`netstat -ano | findstr :8150` on Windows) and stop until the user frees it. Do not quietly
move BookStack elsewhere: 8150 sits inside `APP_URL` and inside every link the wiki stores, so
changing it later means running BookStack's update-url command over the database.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8150 to 0.0.0.0 so a phone can reach it, and do not point `APP_URL` at this
  machine's LAN address. That puts a wiki with one password on every network the user joins.
- Do not configure SMTP, LDAP, SAML or OIDC. The wiki works without all four, and an identity
  provider replaces the account step 7 secured.
````

## docker-compose.yml

```yaml
# BookStack · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installation ... https://www.bookstackapp.com/docs/admin/installation/
#   configuration .. https://github.com/BookStackApp/BookStack/blob/v26.05.3/.env.example.complete
#   image docs ..... https://docs.linuxserver.io/images/docker-bookstack/
#
# BookStack ships no Docker image of its own; its installation page points at
# community docker setups. This file uses the LinuxServer.io one, GPL-3.0,
# which unpacks BookStack's own 26.05.3 release archive onto their Alpine plus
# nginx base image. The application is upstream's, the packaging is not.
#
# Two services: BookStack and the MariaDB holding every shelf, book, chapter
# and page. Every ${...} comes from /srv/bookstack/.env, mode 600, which
# Compose reads and never mounts. Digests read 2026-08-06; both are multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: bookstack-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: bookstack
      MARIADB_USER: bookstack
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/bookstack/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  bookstack:
    image: lscr.io/linuxserver/bookstack:version-v26.05.3@sha256:7f0af07baa41fd6265f5ec57887564d85be03a326f79cb32f926fe735e5313ff
    container_name: bookstack
    restart: unless-stopped
    environment:
      # The image's own `abc` user is remapped to these, so config/ is yours.
      PUID: "${PUID}"
      PGID: "${PGID}"
      TZ: Etc/UTC
      # Every URL BookStack builds comes from this one value, so it carries the
      # https Caddy terminates rather than the plain http the container speaks.
      APP_URL: ${APP_URL}
      # Session and at-rest key. The image halts its init without one.
      APP_KEY: ${APP_KEY}
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: bookstack
      DB_USERNAME: bookstack
      DB_PASSWORD: ${DB_PASSWORD}
      # Caddy terminates TLS. Upstream ships this false by default.
      SESSION_SECURE_COOKIE: "true"
      # Trust the proxy: the audit log then names the reader, not Caddy.
      APP_PROXIES: "*"
      # Neither is a BookStack setting; the application ignores both. They
      # let step 7 hand them to the console command that replaces the account
      # the first migration seeds, without either value reaching
      # a command line or the host's process list.
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
    volumes:
      - /srv/bookstack/config:/config
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/status || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: Caddy alone reaches 8150; the container listens on 80.
      - "127.0.0.1:8150:80"
    depends_on:
      db:
        condition: service_healthy
```

## compose.local.yml

```yaml
# BookStack · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installation ... https://www.bookstackapp.com/docs/admin/installation/
#   configuration .. https://github.com/BookStackApp/BookStack/blob/v26.05.3/.env.example.complete
#   image docs ..... https://docs.linuxserver.io/images/docker-bookstack/
#
# BookStack ships no Docker image of its own; its installation page points at
# community docker setups. This uses the LinuxServer.io one, GPL-3.0, unpacking
# BookStack's own 26.05.3 release archive onto their Alpine and nginx base
# image. The application is upstream's, the packaging is not.
#
# Paths are relative to ~/selfhost/bookstack/, so one file works on all three
# systems. The database is a named volume because MariaDB chowns its data
# directory to a uid Docker Desktop cannot grant on a Windows bind mount;
# config/ is a bind mount, owned by PUID/PGID. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: bookstack-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: bookstack
      MARIADB_USER: bookstack
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - bookstack-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  bookstack:
    image: lscr.io/linuxserver/bookstack:version-v26.05.3@sha256:7f0af07baa41fd6265f5ec57887564d85be03a326f79cb32f926fe735e5313ff
    container_name: bookstack
    restart: unless-stopped
    environment:
      PUID: "${PUID}"
      PGID: "${PGID}"
      TZ: Etc/UTC
      APP_URL: ${APP_URL}
      # Session and at-rest key. The image halts its init without one.
      APP_KEY: ${APP_KEY}
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: bookstack
      DB_USERNAME: bookstack
      DB_PASSWORD: ${DB_PASSWORD}
      # Neither is a BookStack setting. They let step 7 hand them to the
      # command that replaces the seeded account, off the command line.
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
    volumes:
      - ./config:/config
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/status || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi reaches 8150.
      - "127.0.0.1:8150:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  bookstack-dbdata:
```

## Caddyfile

```text
# BookStack · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linuxserver.io/images/docker-bookstack/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also APP_URL in .env, and BookStack
# builds every link it stores from APP_URL, so moving it is a database edit.

<DOMAIN> {
	encode zstd gzip

	# BookStack sets its own content-security and frame-ancestors headers.
	# These four are the ones a reverse proxy is the right place for.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8150 is the loopback port compose publishes here, not a container
	# port and not open in the firewall. Caddy sets X-Forwarded-For, and
	# APP_PROXIES in compose.yml lets BookStack read it.
	reverse_proxy 127.0.0.1:8150
}
```

## install.sh

```bash
#!/usr/bin/env bash
# BookStack · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=wiki.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://www.bookstackapp.com/docs/admin/installation/
#   https://github.com/BookStackApp/BookStack/blob/v26.05.3/.env.example.complete
#   https://docs.linuxserver.io/images/docker-bookstack/
#   https://www.bookstackapp.com/docs/admin/commands/
#
# BookStack publishes no Docker image; its installation page points at community
# docker setups, and this uses the LinuxServer.io one pinned by digest.
#
# Four secrets are generated here, on this machine: the application key, the
# database password, the MariaDB root password and the administrator's password.
# All four go into /srv/bookstack/.env with mode 600 and none is ever printed.
#
# DOMAIN_HOST is also APP_URL, the value BookStack builds every stored link
# from. Choose it once; changing it later is a database rewrite.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/bookstack}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. wiki.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address the administrator will sign in with"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; PHP plus MariaDB wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# mariadb stays root-owned at 700: the MariaDB image chowns its own data
# directory and refuses one somebody claimed first.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/config"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the four secrets, on the server -----------------------------
#
# APP_KEY is 32 random bytes in the base64: form BookStack's own key generator
# emits. Read the administrator password later with
#   sudo grep ADMIN_PASSWORD /srv/bookstack/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		APP_URL=https://${DOMAIN_HOST}
		ADMIN_EMAIL=${ADMIN_EMAIL}
		APP_KEY=base64:$(openssl rand -base64 32)
		DB_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
		ADMIN_PASSWORD=$(openssl rand -hex 24)
	ENVFILE
	printf 'PUID=%s\nPGID=%s\n' "$(id -u)" "$(id -g)" >> "$APP_DIR/.env"
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-bookstack"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8150 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8150 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The image runs BookStack's schema migration on the way up, and that migration
# is what seeds the admin@admin.com account this script then replaces.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/status"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/status" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/status answered ${code:-nothing}. Check: docker compose logs --tail 40 bookstack"

curl -sS "https://${DOMAIN_HOST}/status" | grep -q '"database":true' \
	|| die "/status answered 200 without a healthy database. Check: docker compose logs --tail 40 bookstack"

# Replace the account the first migration seeds. --initial rewrites the name,
# email and password of admin@admin.com in place rather than adding a second
# administrator. Both values come from the container environment, so neither
# reaches this machine's process list. They stay in that environment after,
# where `docker inspect bookstack` can read them: the same boundary as .env
# itself, since the docker group is root-equivalent on this box.
admin_out="$(docker compose exec -T --user abc bookstack sh -c 'php /app/www/artisan bookstack:create-admin --initial --no-ansi --name="Site administrator" --email="$ADMIN_EMAIL" --password="$ADMIN_PASSWORD"')"
printf '%s' "$admin_out" | grep -q 'The default admin user has been updated with the provided details!' \
	|| die "the console command did not report updating the default admin user. Stop and investigate."
unset admin_out

curl -sS "https://${DOMAIN_HOST}/login" | grep -q 'list-heading">Log In<' \
	|| die "the login page did not render its Log In heading"

# The shipped credential must now be refused. A rejected sign-in renders
# BookStack's own wording for bad credentials.
shipped=password
jar="$(mktemp)"
tok="$(curl -sS -c "$jar" "https://${DOMAIN_HOST}/login" | sed -n 's/.*name="_token" value="\([^"]*\)".*/\1/p' | head -1)"
[ -n "$tok" ] || die "no CSRF token on the login page, so the credential check would prove nothing"
curl -sS -b "$jar" -c "$jar" -L -d "_token=$tok" -d "email=admin@admin.com" -d "password=$shipped" "https://${DOMAIN_HOST}/login" \
	| grep -q 'These credentials do not match our records' \
	|| die "the shipped admin credential still works. Stop: this install is not safe to leave running."
rm -f "$jar"
unset shipped tok

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$APP_DIR/backups/bookstack-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/bookstack-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env config -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/bookstack-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/bookstack-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	BookStack is answering at https://${DOMAIN_HOST}/login

	  1. The account BookStack's first migration seeds, admin@admin.com with a
	     published password, has been replaced by ${ADMIN_EMAIL}, and a sign-in
	     attempt with the old pair was checked and refused.
	  2. Your password is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here, and this
	     install configures no mail, so there is no password-reset route.
	  3. APP_KEY in that same file encrypts what BookStack stores encrypted.
	     A restore without it is a restore of unreadable rows.
	  4. First backup written to $APP_DIR/backups: a database dump and a config
	     archive holding compose.yml, .env, the uploads under config/ and the
	     live Caddy site block. They are on the same disk as the data, which is
	     not a backup. Copy them somewhere else tonight.

DONE
```

## Also evaluated

Ranked below BookStack for this swap. The prompts above install BookStack only.

- **Docmost** — Spaces, nested pages and an editor two people can type in at once, with every word in a Postgres you can dump. The Notion-shaped answer, and the one to pick if the thing your team misses most is two people typing in the same document at once. Docmost has spaces, nested pages and a realtime collaborative editor, which BookStack does not, and it keeps everything in Postgres. It ranks second here only because Slab's own model is topics and posts rather than an infinite page tree, so BookStack's book-and-chapter structure lands closer to what a Slab team already has.

The page this mirrors: https://caniselfhostit.com/self-host/slab/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
