<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://wiki.wilsoz.com/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Wikibot</id>
	<title>Stronghold Wiki - User contributions [en]</title>
	<link rel="self" type="application/atom+xml" href="https://wiki.wilsoz.com/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Wikibot"/>
	<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php/Special:Contributions/Wikibot"/>
	<updated>2026-07-06T06:55:39Z</updated>
	<subtitle>User contributions</subtitle>
	<generator>MediaWiki 1.43.9</generator>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/Authentik&amp;diff=29</id>
		<title>Services/Authentik</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/Authentik&amp;diff=29"/>
		<updated>2026-07-05T18:25:21Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Genericize example password placeholder&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from services/AUTHENTIK.md&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Authentik — Identity &amp;amp; SSO Provider =&lt;br /&gt;
&lt;br /&gt;
Complete setup &amp;amp; operations guide for Authentik (identity provider, OAuth2/OIDC, LDAP directory service).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Criticality:&#039;&#039;&#039; &#039;&#039;&#039;CRITICAL — The lynchpin.&#039;&#039;&#039; Everything on Stronghold depends on Authentik for authentication. Without it, most other services are inaccessible or in an inconsistent state. Must be recovered first in any disaster scenario.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Authentik&#039;&#039;&#039; is a self-hosted identity provider that serves as the single source of truth for authentication across Stronghold. It provides:&lt;br /&gt;
* &#039;&#039;&#039;OAuth2 / OIDC&#039;&#039;&#039; providers for web apps (Immich, Memos, Linkwarden, Forgejo, Vaultwarden, Grafana, n8n, Matrix, Cloudflare Access)&lt;br /&gt;
* &#039;&#039;&#039;LDAP directory&#039;&#039;&#039; for apps that don&#039;t support OAuth (Nextcloud, Jellyfin, Stalwart mail server)&lt;br /&gt;
* &#039;&#039;&#039;Group membership&#039;&#039;&#039; for role-based access (authentik Admins, family-and-friends, email provisioning groups)&lt;br /&gt;
* &#039;&#039;&#039;User management&#039;&#039;&#039; — all users are defined here; other services sync from Authentik&lt;br /&gt;
* &#039;&#039;&#039;API tokens&#039;&#039;&#039; for programmatic access (n8n, CI/CD, automation)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Users:&#039;&#039;&#039; &lt;br /&gt;
* &amp;lt;code&amp;gt;marcus&amp;lt;/code&amp;gt; (primary admin user, member of authentik Admins)&lt;br /&gt;
* &amp;lt;code&amp;gt;akadmin&amp;lt;/code&amp;gt; (backup admin user)&lt;br /&gt;
* Service accounts: &amp;lt;code&amp;gt;stalwart-svc&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;nextcloud-svc&amp;lt;/code&amp;gt; (for LDAP directory access)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Version:&#039;&#039;&#039; 2025.10.3&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Access:&#039;&#039;&#039; https://key.wilsoz.com/if/admin/&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Service Information ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Property&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Compose file&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/authentik-app/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Containers&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-app_server_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;authentik-app_worker_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;authentik-app_postgresql_1&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Database&#039;&#039;&#039;&lt;br /&gt;
| PostgreSQL 16 (in-container, persisted to Docker volume &amp;lt;code&amp;gt;database&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HTTP Port&#039;&#039;&#039;&lt;br /&gt;
| 9000 (external via 9443 HTTPS via reverse proxy)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HTTPS Port&#039;&#039;&#039;&lt;br /&gt;
| 9443 (internal, for direct connections)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;LDAP Outpost Port&#039;&#039;&#039;&lt;br /&gt;
| 389 (via LDAP Outpost container; internal only, reaches stronghold via &amp;lt;code&amp;gt;http://172.17.0.1:9000&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Data volumes&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/authentik-app/media&amp;lt;/code&amp;gt; (custom templates, branding)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Database volume&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-app_database&amp;lt;/code&amp;gt; (PostgreSQL data)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Networks&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-app_default&amp;lt;/code&amp;gt; (internal Docker network, MTU 1280)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Image versions&#039;&#039;&#039;&lt;br /&gt;
| server/worker: &amp;lt;code&amp;gt;ghcr.io/goauthentik/server:2025.10.3&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Restart policy&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;unless-stopped&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Initial Setup from Scratch ==&lt;br /&gt;
&lt;br /&gt;
=== Prerequisites ===&lt;br /&gt;
&lt;br /&gt;
* Docker and docker-compose installed&lt;br /&gt;
* Network access to PostgreSQL (internal container or external)&lt;br /&gt;
* Cloudflare DNS configured for &amp;lt;code&amp;gt;key.wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
* VPS reverse proxy (nginx) configured to forward to stronghold:9000&lt;br /&gt;
&lt;br /&gt;
=== 1. Create Directory &amp;amp; Files ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p /library/authentik-app&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Create docker-compose.yml ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;yaml&amp;quot;&amp;gt;&lt;br /&gt;
version: &amp;quot;3.8&amp;quot;&lt;br /&gt;
&lt;br /&gt;
services:&lt;br /&gt;
  postgresql:&lt;br /&gt;
    image: docker.io/library/postgres:16-alpine&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    healthcheck:&lt;br /&gt;
      test: [&amp;quot;CMD-SHELL&amp;quot;, &amp;quot;pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}&amp;quot;]&lt;br /&gt;
      interval: 30s&lt;br /&gt;
      timeout: 5s&lt;br /&gt;
      retries: 5&lt;br /&gt;
      start_period: 20s&lt;br /&gt;
    volumes:&lt;br /&gt;
      - database:/var/lib/postgresql/data&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
    environment:&lt;br /&gt;
      POSTGRES_DB: ${PG_DB:-authentik}&lt;br /&gt;
      POSTGRES_USER: ${PG_USER:-authentik}&lt;br /&gt;
      POSTGRES_PASSWORD: ${PG_PASS:?database password required}&lt;br /&gt;
&lt;br /&gt;
  server:&lt;br /&gt;
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.3}&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    command: server&lt;br /&gt;
    ports:&lt;br /&gt;
      - &amp;quot;${COMPOSE_PORT_HTTP:-9000}:9000&amp;quot;&lt;br /&gt;
      - &amp;quot;${COMPOSE_PORT_HTTPS:-9443}:9443&amp;quot;&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
    environment:&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__HOST: postgresql&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}&lt;br /&gt;
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}&lt;br /&gt;
    volumes:&lt;br /&gt;
      - ./media:/media&lt;br /&gt;
      - ./custom-templates:/templates&lt;br /&gt;
    depends_on:&lt;br /&gt;
      postgresql:&lt;br /&gt;
        condition: service_healthy&lt;br /&gt;
&lt;br /&gt;
  worker:&lt;br /&gt;
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.3}&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    command: worker&lt;br /&gt;
    user: root&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
    environment:&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__HOST: postgresql&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}&lt;br /&gt;
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}&lt;br /&gt;
    volumes:&lt;br /&gt;
      - /var/run/docker.sock:/var/run/docker.sock&lt;br /&gt;
      - ./media:/media&lt;br /&gt;
      - ./certs:/certs&lt;br /&gt;
      - ./custom-templates:/templates&lt;br /&gt;
    depends_on:&lt;br /&gt;
      postgresql:&lt;br /&gt;
        condition: service_healthy&lt;br /&gt;
&lt;br /&gt;
volumes:&lt;br /&gt;
  database:&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. Create .env File ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# PostgreSQL&lt;br /&gt;
PG_DB=authentik&lt;br /&gt;
PG_USER=authentik&lt;br /&gt;
PG_PASS=&amp;lt;generate-strong-password-32-chars&amp;gt;&lt;br /&gt;
&lt;br /&gt;
# Authentik&lt;br /&gt;
AUTHENTIK_SECRET_KEY=&amp;lt;generate-strong-secret-key-50-chars&amp;gt;&lt;br /&gt;
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server&lt;br /&gt;
AUTHENTIK_TAG=2025.10.3&lt;br /&gt;
&lt;br /&gt;
# Ports&lt;br /&gt;
COMPOSE_PORT_HTTP=9000&lt;br /&gt;
COMPOSE_PORT_HTTPS=9443&lt;br /&gt;
&lt;br /&gt;
# Bootstrap (initial admin)&lt;br /&gt;
AUTHENTIK_BOOTSTRAP_PASSWORD=&amp;lt;temporary-init-password&amp;gt;&lt;br /&gt;
AUTHENTIK_BOOTSTRAP_TOKEN=&amp;lt;temporary-init-token&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important:&#039;&#039;&#039; Generate secure random values for passwords and secrets:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
openssl rand -base64 32  # For PG_PASS and AUTHENTIK_SECRET_KEY&lt;br /&gt;
openssl rand -hex 16     # For AUTHENTIK_BOOTSTRAP_TOKEN&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4. Start Authentik ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Wait for PostgreSQL to be healthy and server to start (~30-60 seconds):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker compose logs -f server&lt;br /&gt;
# Watch for &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5. Initial Admin User ===&lt;br /&gt;
&lt;br /&gt;
After startup, navigate to &amp;lt;code&amp;gt;https://key.wilsoz.com/if/admin/&amp;lt;/code&amp;gt;:&lt;br /&gt;
* Username: &amp;lt;code&amp;gt;akadmin&amp;lt;/code&amp;gt;&lt;br /&gt;
* Password: value of &amp;lt;code&amp;gt;AUTHENTIK_BOOTSTRAP_PASSWORD&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;First steps in Admin UI:&#039;&#039;&#039;&lt;br /&gt;
# Go to &#039;&#039;&#039;Directory → Users → Create&#039;&#039;&#039;&lt;br /&gt;
** Username: &amp;lt;code&amp;gt;marcus&amp;lt;/code&amp;gt;&lt;br /&gt;
** Email: &amp;lt;code&amp;gt;marcus@wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
** Set password (or let user set on first login)&lt;br /&gt;
&#039;&#039;&#039; Groups: Add to &#039;&#039;&#039;authentik Admins**&lt;br /&gt;
&lt;br /&gt;
# Go to &#039;&#039;&#039;Directory → Groups → Create&#039;&#039;&#039;&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;authentik Admins** (if not already created)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;family-and-friends** (for app access)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;craniumslows-email** (for email provisioning)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;wilsoz-email** (for email provisioning)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;ldap-search** (for LDAP bind accounts)&lt;br /&gt;
&lt;br /&gt;
# Add service users:&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Directory → Users → Create**&lt;br /&gt;
** Username: &amp;lt;code&amp;gt;stalwart-svc&amp;lt;/code&amp;gt;, Email: &amp;lt;code&amp;gt;stalwart-svc@internal&amp;lt;/code&amp;gt;&lt;br /&gt;
&#039;&#039;&#039; Groups: Add to &#039;&#039;&#039;ldap-search**&lt;br /&gt;
** Username: &amp;lt;code&amp;gt;nextcloud-svc&amp;lt;/code&amp;gt;, Email: &amp;lt;code&amp;gt;nextcloud-svc@internal&amp;lt;/code&amp;gt;&lt;br /&gt;
&#039;&#039;&#039; Groups: Add to &#039;&#039;&#039;ldap-search**&lt;br /&gt;
&lt;br /&gt;
=== 6. Set Up LDAP Outpost ===&lt;br /&gt;
&lt;br /&gt;
LDAP is required for Stalwart (mail server), Nextcloud (CalDAV/CardDAV), and Jellyfin (media server) to authenticate.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;In Authentik Admin UI:&#039;&#039;&#039;&lt;br /&gt;
# Go to &#039;&#039;&#039;Infrastructure → Outposts → Create&#039;&#039;&#039;&lt;br /&gt;
** Name: &amp;lt;code&amp;gt;authentik-ldap&amp;lt;/code&amp;gt;&lt;br /&gt;
** Type: LDAP&lt;br /&gt;
# In &#039;&#039;&#039;Directory → LDAP → Configure LDAP&#039;&#039;&#039;&lt;br /&gt;
** Base DN: &amp;lt;code&amp;gt;dc=ldap,dc=goauthentik,dc=io&amp;lt;/code&amp;gt;&lt;br /&gt;
** User base: &amp;lt;code&amp;gt;ou=users,dc=ldap,dc=goauthentik,dc=io&amp;lt;/code&amp;gt;&lt;br /&gt;
# Assign the LDAP outpost to the LDAP provider&lt;br /&gt;
&lt;br /&gt;
The LDAP server will be reachable at &amp;lt;code&amp;gt;172.17.0.1:389&amp;lt;/code&amp;gt; from other containers.&lt;br /&gt;
&lt;br /&gt;
=== 7. Configure OAuth2 Providers ===&lt;br /&gt;
&lt;br /&gt;
For each OAuth application (Immich, Memos, Linkwarden, etc.), create an OAuth2 provider in Authentik:&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Immich example:&#039;&#039;&#039;&lt;br /&gt;
# Go to &#039;&#039;&#039;Applications → Providers → Create OAuth2/OpenID Provider&#039;&#039;&#039;&lt;br /&gt;
** Name: &amp;lt;code&amp;gt;immich-oauth&amp;lt;/code&amp;gt;&lt;br /&gt;
** Authorization flow: &amp;lt;code&amp;gt;default-authentication-flow&amp;lt;/code&amp;gt;&lt;br /&gt;
** Client type: &amp;lt;code&amp;gt;Confidential&amp;lt;/code&amp;gt;&lt;br /&gt;
** Redirect URIs:&lt;br /&gt;
     ```&lt;br /&gt;
     https://photos.wilsoz.com/auth/login/oauth2/callback&lt;br /&gt;
     https://photos.wilsoz.com/auth/login/oauth2/mobile-redirect&lt;br /&gt;
     ```&lt;br /&gt;
# &#039;&#039;&#039;Save&#039;&#039;&#039; — copy the generated Client ID and Client Secret&lt;br /&gt;
# Add property mappings:&lt;br /&gt;
** &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;openid&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
** &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;email&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
** &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;profile&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
# Enable: &amp;lt;code&amp;gt;include_claims_in_id_token&amp;lt;/code&amp;gt;&lt;br /&gt;
# &#039;&#039;&#039;Assign a signing key&#039;&#039;&#039; (see Critical Gotchas section below)&lt;br /&gt;
&lt;br /&gt;
Repeat for each app.&lt;br /&gt;
&lt;br /&gt;
=== 8. Configure VPS Reverse Proxy ===&lt;br /&gt;
&lt;br /&gt;
On VPS, add nginx server block:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;nginx&amp;quot;&amp;gt;&lt;br /&gt;
server {&lt;br /&gt;
    listen 443 ssl http2;&lt;br /&gt;
    server_name key.wilsoz.com;&lt;br /&gt;
&lt;br /&gt;
    ssl_certificate /etc/letsencrypt/live/key.wilsoz.com/fullchain.pem;&lt;br /&gt;
    ssl_certificate_key /etc/letsencrypt/live/key.wilsoz.com/privkey.pem;&lt;br /&gt;
&lt;br /&gt;
    location / {&lt;br /&gt;
        proxy_pass http://100.64.207.155:9000;&lt;br /&gt;
        proxy_set_header Host $host;&lt;br /&gt;
        proxy_set_header X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Reload nginx:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo systemctl reload nginx&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disaster Mode A: Configuration Corruption ==&lt;br /&gt;
&lt;br /&gt;
=== Symptoms ===&lt;br /&gt;
&lt;br /&gt;
* Can&#039;t log into admin interface&lt;br /&gt;
* OAuth providers missing or broken&lt;br /&gt;
* LDAP directory returning errors&lt;br /&gt;
* Groups or users deleted accidentally&lt;br /&gt;
* Provider credentials lost&lt;br /&gt;
&lt;br /&gt;
=== Recovery Steps ===&lt;br /&gt;
&lt;br /&gt;
⚠️ &#039;&#039;&#039;This mode resets Authentik to a fresh state. User data will be lost unless you have a PostgreSQL backup (see Disaster Mode B).&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==== Option 1: Full Reset (if Disaster Mode B not available) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
&lt;br /&gt;
# Stop all containers&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Remove the database volume (this deletes all Authentik data)&lt;br /&gt;
docker volume rm authentik-app_database&lt;br /&gt;
&lt;br /&gt;
# Start from scratch&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Wait for startup&lt;br /&gt;
sleep 30&lt;br /&gt;
docker logs server | grep &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Follow &amp;quot;Initial Setup from Scratch&amp;quot; section above&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Option 2: Selective Reset (if you only need to fix one app) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Access the Django shell&lt;br /&gt;
docker exec authentik-app_server_1 ak shell&lt;br /&gt;
&lt;br /&gt;
# Example: Fix an OAuth provider&#039;s signing key&lt;br /&gt;
from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
from authentik.crypto.models import CertificateKeyPair&lt;br /&gt;
&lt;br /&gt;
p = OAuth2Provider.objects.get(name=&#039;immich-oauth&#039;)&lt;br /&gt;
jwt_key = CertificateKeyPair.objects.get(name=&#039;authentik Internal JWT Certificate&#039;)&lt;br /&gt;
p.signing_key = jwt_key&lt;br /&gt;
p.save()&lt;br /&gt;
print(f&#039;Updated {p.name} with JWT signing key&#039;)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disaster Mode B: Drive Failure / Full Recovery ==&lt;br /&gt;
&lt;br /&gt;
=== If Backups Accessible ===&lt;br /&gt;
&lt;br /&gt;
Authentik data is backed up in two ways:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;PostgreSQL dump&#039;&#039;&#039; at Hetzner (in &amp;lt;code&amp;gt;/dumps/authentik-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;)&lt;br /&gt;
# &#039;&#039;&#039;Media files&#039;&#039;&#039; at Hetzner (in &amp;lt;code&amp;gt;/data/authentik/&amp;lt;/code&amp;gt;)&lt;br /&gt;
&lt;br /&gt;
==== Recovery procedure: ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Set up fresh Authentik (follow &amp;quot;Initial Setup from Scratch&amp;quot; through step 4)&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 2. Wait for PostgreSQL to be ready&lt;br /&gt;
sleep 30&lt;br /&gt;
&lt;br /&gt;
# 3. Drop the initial empty database&lt;br /&gt;
docker exec authentik-app_postgresql_1 psql -U authentik -d postgres -c &amp;quot;DROP DATABASE authentik;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# 4. Create a fresh empty database&lt;br /&gt;
docker exec authentik-app_postgresql_1 psql -U authentik -d postgres -c &amp;quot;CREATE DATABASE authentik;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# 5. Restore from backup dump (substitute actual date)&lt;br /&gt;
# First, extract the backup from Hetzner:&lt;br /&gt;
# (You&#039;ll need ssh-key access to u540853.your-storagebox.de port 23)&lt;br /&gt;
ssh -p 23 u540853@u540853.your-storagebox.de &amp;quot;cat backups/authentik-20260512.sql.gz&amp;quot; | \&lt;br /&gt;
  gunzip | docker exec -i authentik-app_postgresql_1 psql -U authentik -d authentik&lt;br /&gt;
&lt;br /&gt;
# 6. Restore media files&lt;br /&gt;
rsync -av -e &amp;quot;ssh -p 23&amp;quot; u540853@u540853.your-storagebox.de:backups/data/authentik/ /library/authentik-app/media/&lt;br /&gt;
&lt;br /&gt;
# 7. Restart Authentik to pick up restored data&lt;br /&gt;
docker compose restart server worker&lt;br /&gt;
&lt;br /&gt;
# 8. Verify&lt;br /&gt;
docker logs server | grep &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
curl -s https://key.wilsoz.com/if/admin/ | grep -q &amp;quot;authentik&amp;quot; &amp;amp;&amp;amp; echo &amp;quot;Admin UI responding&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== If Backups NOT Accessible ===&lt;br /&gt;
&lt;br /&gt;
Fall back to Mode A (full reset). Note: &#039;&#039;&#039;All user data, OAuth provider credentials, and LDAP settings will be lost.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
After reset, you&#039;ll need to:&lt;br /&gt;
# Recreate all users and groups&lt;br /&gt;
# Recreate all OAuth2 providers and get new Client IDs/Secrets&lt;br /&gt;
# Update all dependent services (.env files) with new credentials&lt;br /&gt;
# Recreate LDAP configuration and outpost&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Data Locations ==&lt;br /&gt;
&lt;br /&gt;
=== Authentik Data on Host ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
/library/authentik-app/&lt;br /&gt;
├── docker-compose.yml       (service definition)&lt;br /&gt;
├── .env                     (secrets: PG_PASS, AUTHENTIK_SECRET_KEY)&lt;br /&gt;
├── media/                   (custom templates, branding, uploads)&lt;br /&gt;
└── custom-templates/        (HTML templates for login pages, etc.)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== PostgreSQL Database Volume ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
authentik-app_database (Docker volume)&lt;br /&gt;
├── postgresql/              (database files)&lt;br /&gt;
└── ...&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Contains:&lt;br /&gt;
* All users, groups, group memberships&lt;br /&gt;
* All OAuth2 providers, applications, credentials&lt;br /&gt;
* All LDAP configuration&lt;br /&gt;
* All API tokens&lt;br /&gt;
* All authenticator devices (TOTP, WebAuthn)&lt;br /&gt;
* All event logs&lt;br /&gt;
&lt;br /&gt;
=== Backup Locations ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL dump:&#039;&#039;&#039; Hetzner &amp;lt;code&amp;gt;/dumps/authentik-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt; (daily at 03:00 CST)&lt;br /&gt;
* &#039;&#039;&#039;Media files:&#039;&#039;&#039; Hetzner &amp;lt;code&amp;gt;/data/authentik/&amp;lt;/code&amp;gt; (daily Restic snapshot)&lt;br /&gt;
* &#039;&#039;&#039;Filesystem snapshot:&#039;&#039;&#039; Timeshift snapshot on &amp;lt;code&amp;gt;/dev/md1&amp;lt;/code&amp;gt; (weekly)&lt;br /&gt;
&lt;br /&gt;
=== What&#039;s NOT Backed Up ===&lt;br /&gt;
&lt;br /&gt;
* Container images (re-downloadable from Docker Hub)&lt;br /&gt;
* node_modules and build artifacts (not present in Authentik)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Critical Gotchas ==&lt;br /&gt;
&lt;br /&gt;
=== 1. OAuth2 Signing Key Assignment — &#039;&#039;&#039;MANDATORY&#039;&#039;&#039; ===&lt;br /&gt;
&lt;br /&gt;
⚠️ &#039;&#039;&#039;If you don&#039;t assign a signing key to an OAuth2 provider, the JWKS endpoint returns an empty object &amp;lt;code&amp;gt;{}&amp;lt;/code&amp;gt;, and all clients trying to validate tokens will fail.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Every OAuth2 provider MUST have:&#039;&#039;&#039;&lt;br /&gt;
* A signing key assigned: Use &#039;&#039;&#039;&amp;lt;code&amp;gt;authentik Internal JWT Certificate&amp;lt;/code&amp;gt;&#039;&#039;&#039; (HS256 algorithm)&lt;br /&gt;
* &#039;&#039;&#039;NOT&#039;&#039;&#039; &amp;lt;code&amp;gt;authentik Self-signed Certificate&amp;lt;/code&amp;gt; (RS256) — some apps like Mealie reject RS256&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Assignment code:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
from authentik.crypto.models import CertificateKeyPair&lt;br /&gt;
&lt;br /&gt;
p = OAuth2Provider.objects.get(name=&#039;immich-oauth&#039;)&lt;br /&gt;
jwt_key = CertificateKeyPair.objects.get(name=&#039;authentik Internal JWT Certificate&#039;)&lt;br /&gt;
p.signing_key = jwt_key&lt;br /&gt;
p.save()&lt;br /&gt;
print(f&#039;Assigned signing key to {p.name}&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Property Mappings for OAuth Scopes ===&lt;br /&gt;
&lt;br /&gt;
OAuth providers need explicit property mappings for scopes like &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;profile&amp;lt;/code&amp;gt;. Without them:&lt;br /&gt;
* OIDC discovery endpoint returns only &amp;lt;code&amp;gt;[&amp;quot;openid&amp;quot;]&amp;lt;/code&amp;gt;&lt;br /&gt;
* Apps can&#039;t get user email or name from token&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Always add these three mappings to every OAuth provider:&#039;&#039;&#039;&lt;br /&gt;
* &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;openid&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;email&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;profile&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Also enable: &amp;lt;code&amp;gt;include_claims_in_id_token = True&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. LDAP Outpost Internal-Only ===&lt;br /&gt;
&lt;br /&gt;
The LDAP outpost (port 389) is &#039;&#039;&#039;internal to Docker networking only&#039;&#039;&#039;. External apps can&#039;t reach it directly. Instead:&lt;br /&gt;
* Internal Docker containers reach it at &amp;lt;code&amp;gt;172.17.0.1:389&amp;lt;/code&amp;gt;&lt;br /&gt;
* Other hosts on LAN reach it via Stalwart LDAP passthrough (Stalwart exposes LDAP to port 389 externally)&lt;br /&gt;
&lt;br /&gt;
=== 4. Bootstrap Credentials Not Persisted ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;AUTHENTIK_BOOTSTRAP_PASSWORD&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;AUTHENTIK_BOOTSTRAP_TOKEN&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; are only used on first startup. After that, they&#039;re ignored. If you lose them:&lt;br /&gt;
* You still have the &amp;lt;code&amp;gt;akadmin&amp;lt;/code&amp;gt; user created from bootstrap&lt;br /&gt;
* If you lose the akadmin password, reset via Django shell:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.core.models import User&lt;br /&gt;
u = User.objects.get(username=&#039;akadmin&#039;)&lt;br /&gt;
u.set_password(&#039;&amp;lt;choose-a-new-password-here&amp;gt;&#039;)&lt;br /&gt;
u.save()&lt;br /&gt;
print(f&#039;Reset password for {u.username}&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5. Secret Key Rotation (Rare but Important) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;AUTHENTIK_SECRET_KEY&amp;lt;/code&amp;gt; is used to encrypt stored secrets (OAuth credentials, etc.). &#039;&#039;&#039;If you change this .env value, all encrypted secrets become unreadable.&#039;&#039;&#039; Only change if:&lt;br /&gt;
* You&#039;re migrating to a new server&lt;br /&gt;
* You suspect key compromise&lt;br /&gt;
&lt;br /&gt;
When rotating:&lt;br /&gt;
# Export all OAuth providers&#039; client secrets before changing&lt;br /&gt;
# Update AUTHENTIK_SECRET_KEY&lt;br /&gt;
# Restart (secrets will need to be re-entered)&lt;br /&gt;
&lt;br /&gt;
=== 6. Group Membership Propagation Delay ===&lt;br /&gt;
&lt;br /&gt;
Changes to group membership (e.g., adding a user to a group) are propagated asynchronously via the worker. If an app isn&#039;t seeing the new group:&lt;br /&gt;
* Wait 5-10 seconds&lt;br /&gt;
* Restart the worker: &amp;lt;code&amp;gt;docker compose restart worker&amp;lt;/code&amp;gt;&lt;br /&gt;
* Check the worker logs: &amp;lt;code&amp;gt;docker logs worker | tail -20&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 7. API Token Expiry ===&lt;br /&gt;
&lt;br /&gt;
API tokens can have an expiry. The current claude-api-token (created 2026-02-16) is set to &#039;&#039;&#039;never expire&#039;&#039;&#039;. Check token settings:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.core.models import Token&lt;br /&gt;
t = Token.objects.get(identifier=&#039;claude-api-token&#039;)&lt;br /&gt;
print(f&#039;Token: {t.identifier}&#039;)&lt;br /&gt;
print(f&#039;Expires: {t.expires}&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If a token expires, create a new one in the Admin UI (User → Tokens) and update all scripts/services using it.&lt;br /&gt;
&lt;br /&gt;
=== 8. LDAP Bind Accounts Must Be in ldap-search Group ===&lt;br /&gt;
&lt;br /&gt;
Any user/service that needs to query the LDAP directory must be a member of the &#039;&#039;&#039;&amp;lt;code&amp;gt;ldap-search&amp;lt;/code&amp;gt;&#039;&#039;&#039; group. Currently:&lt;br /&gt;
* &amp;lt;code&amp;gt;stalwart-svc&amp;lt;/code&amp;gt; — for mail server LDAP lookups&lt;br /&gt;
* &amp;lt;code&amp;gt;nextcloud-svc&amp;lt;/code&amp;gt; — for CalDAV/CardDAV user sync&lt;br /&gt;
&lt;br /&gt;
Without group membership, LDAP bind fails with &amp;quot;Access Denied&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Verification Checklist ==&lt;br /&gt;
&lt;br /&gt;
After recovery, verify:&lt;br /&gt;
&lt;br /&gt;
* [ ] &#039;&#039;&#039;Admin UI accessible:&#039;&#039;&#039; &amp;lt;code&amp;gt;https://key.wilsoz.com/if/admin/&amp;lt;/code&amp;gt; → login as marcus&lt;br /&gt;
* [ ] &#039;&#039;&#039;OAuth discovery endpoint working:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  curl -s https://key.wilsoz.com/application/o/immich/.well-known/openid-configuration | jq &#039;.scopes_supported&#039;&lt;br /&gt;
  # Should include: [&amp;quot;openid&amp;quot;, &amp;quot;email&amp;quot;, &amp;quot;profile&amp;quot;]&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;JWKS endpoint has keys:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  curl -s https://key.wilsoz.com/application/o/immich/jwks/ | jq &#039;.keys | length&#039;&lt;br /&gt;
  # Should return a number &amp;gt; 0&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;LDAP directory responding:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  ldapsearch -H ldap://172.17.0.1:389 -D &amp;quot;cn=stalwart-svc,ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; \&lt;br /&gt;
    -w &amp;lt;stalwart-svc-password&amp;gt; -b &amp;quot;ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; &amp;quot;uid=*&amp;quot; | grep &amp;quot;numResponses&amp;quot;&lt;br /&gt;
  # Should return matches (or at least show successful bind)&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Users present:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker exec authentik-app_server_1 ak shell -c &amp;quot;from authentik.core.models import User; print(&#039;\n&#039;.join([u.username for u in User.objects.all()]))&amp;quot;&lt;br /&gt;
  # Should show: akadmin, marcus, stalwart-svc, nextcloud-svc&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Groups present:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker exec authentik-app_server_1 ak shell -c &amp;quot;from authentik.core.models import Group; print(&#039;\n&#039;.join([g.name for g in Group.objects.all()]))&amp;quot;&lt;br /&gt;
  # Should show all configured groups&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;OAuth providers have signing keys:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
  from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
  for p in OAuth2Provider.objects.all():&lt;br /&gt;
    has_key = p.signing_key is not None&lt;br /&gt;
    print(f&#039;{p.name}: signing_key={has_key}&#039;)&lt;br /&gt;
  &amp;quot;&lt;br /&gt;
  # All should show: signing_key=True&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Dependent services can authenticate:&#039;&#039;&#039;&lt;br /&gt;
** [ ] Immich: Try OAuth login at https://photos.wilsoz.com&lt;br /&gt;
** [ ] Linkwarden: Try OAuth login at https://bookmarks.wilsoz.com&lt;br /&gt;
** [ ] Memos: Try OAuth login at https://notes.wilsoz.com&lt;br /&gt;
** [ ] Stalwart IMAP: Test LDAP lookup for mail routing&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== What Authentik Depends On ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL 16&#039;&#039;&#039; — must be healthy before server/worker start&lt;br /&gt;
* &#039;&#039;&#039;Docker&#039;&#039;&#039; — for container runtime&lt;br /&gt;
* &#039;&#039;&#039;Network access to Cloudflare DNS&#039;&#039;&#039; — for DNS resolution of &amp;lt;code&amp;gt;key.wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;VPS reverse proxy&#039;&#039;&#039; — nginx on VPS must forward &amp;lt;code&amp;gt;key.wilsoz.com:443&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;stronghold:9000&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== What Depends On Authentik ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;All OAuth2 consumers:&#039;&#039;&#039; Immich, Memos, Linkwarden, Forgejo, Vaultwarden, Grafana, n8n, Matrix&lt;br /&gt;
* &#039;&#039;&#039;All LDAP consumers:&#039;&#039;&#039; Nextcloud, Jellyfin, Stalwart (mail server)&lt;br /&gt;
* &#039;&#039;&#039;Cloudflare Access:&#039;&#039;&#039; For gating n8n form URLs&lt;br /&gt;
* &#039;&#039;&#039;Email provisioning workflow (n8n):&#039;&#039;&#039; Watches Authentik groups for email user provisioning&lt;br /&gt;
* &#039;&#039;&#039;Nginx reverse proxy:&#039;&#039;&#039; Serves key.wilsoz.com → Authentik&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Operations ==&lt;br /&gt;
&lt;br /&gt;
=== Start/Stop/Restart ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
&lt;br /&gt;
# Start&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Stop&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Restart&lt;br /&gt;
docker compose restart server worker&lt;br /&gt;
&lt;br /&gt;
# Full restart (clears caches)&lt;br /&gt;
docker compose down &amp;amp;&amp;amp; sleep 2 &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View Logs ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Server logs&lt;br /&gt;
docker logs -f server&lt;br /&gt;
&lt;br /&gt;
# Worker logs (for background tasks)&lt;br /&gt;
docker logs -f worker&lt;br /&gt;
&lt;br /&gt;
# PostgreSQL logs (for DB issues)&lt;br /&gt;
docker logs postgresql&lt;br /&gt;
&lt;br /&gt;
# All together&lt;br /&gt;
docker compose logs -f&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Access Django Shell (Advanced) ===&lt;br /&gt;
&lt;br /&gt;
For one-off admin tasks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell&lt;br /&gt;
&lt;br /&gt;
# Example: List all users&lt;br /&gt;
from authentik.core.models import User&lt;br /&gt;
for u in User.objects.all():&lt;br /&gt;
    print(f&amp;quot;{u.username}: {u.email}, groups: {list(u.groups.values_list(&#039;name&#039;, flat=True))}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
# Example: Create API token&lt;br /&gt;
from authentik.core.models import Token&lt;br /&gt;
t = Token.objects.create(user=User.objects.get(username=&#039;marcus&#039;), identifier=&#039;my-api-key&#039;)&lt;br /&gt;
print(f&amp;quot;Token: {t.key}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
# Exit: Ctrl+D&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Backup PostgreSQL Manually ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Full database dump&lt;br /&gt;
docker exec authentik-app_postgresql_1 pg_dump -U authentik authentik | \&lt;br /&gt;
  gzip &amp;gt; /home/mnw/backups/authentik-manual-$(date +%Y%m%d-%H%M%S).sql.gz&lt;br /&gt;
&lt;br /&gt;
# Or via Restic (for Hetzner backup)&lt;br /&gt;
cd /library/backup-stack &amp;amp;&amp;amp; docker compose exec restic-backup bash -c &amp;quot;&lt;br /&gt;
  export RESTIC_REPOSITORY=\$HETZNER_REPO&lt;br /&gt;
  export RESTIC_PASSWORD=\$HETZNER_PASSWORD&lt;br /&gt;
  # Make dump available in /data&lt;br /&gt;
  docker exec authentik-app_postgresql_1 pg_dump -U authentik authentik | gzip &amp;gt; /data/dumps/authentik-manual.sql.gz&lt;br /&gt;
  restic backup /data/dumps/&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t log into admin UI ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; 403 Forbidden, or password rejected&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fixes:&#039;&#039;&#039;&lt;br /&gt;
# Check if server is running: &amp;lt;code&amp;gt;docker ps | grep server&amp;lt;/code&amp;gt;&lt;br /&gt;
# Reset akadmin password via Django shell (see Operations section)&lt;br /&gt;
# Check logs: &amp;lt;code&amp;gt;docker logs server | grep -i &amp;quot;error\|auth&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== OAuth login fails with &amp;quot;invalid_grant&amp;quot; ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; Client authentication failed, or code exchange error&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Causes &amp;amp; fixes:&#039;&#039;&#039;&lt;br /&gt;
* Client secret mismatch: Verify in Authentik provider matches app&#039;s .env&lt;br /&gt;
* Provider deleted: Recreate OAuth provider and update app config&lt;br /&gt;
* Redirect URI mismatch: Ensure app&#039;s callback URL matches provider&#039;s redirect URIs exactly&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Debug:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs server | grep -i &amp;quot;oauth\|invalid_grant&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== LDAP bind fails ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; Stalwart/Nextcloud can&#039;t authenticate users&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fixes:&#039;&#039;&#039;&lt;br /&gt;
# Verify LDAP outpost is running: &amp;lt;code&amp;gt;docker ps | grep outposts&amp;lt;/code&amp;gt;&lt;br /&gt;
# Check user is in &amp;lt;code&amp;gt;ldap-search&amp;lt;/code&amp;gt; group: Admin UI → Directory → Groups → ldap-search&lt;br /&gt;
# Test bind from stronghold:&lt;br /&gt;
   ```bash&lt;br /&gt;
   ldapsearch -H ldap://172.17.0.1:389 -D &amp;quot;cn=stalwart-svc,ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; \&lt;br /&gt;
     -w &amp;lt;password&amp;gt; -b &amp;quot;ou=users&amp;quot; &amp;quot;uid=marcus&amp;quot;&lt;br /&gt;
   ```&lt;br /&gt;
&lt;br /&gt;
=== Worker not picking up changes ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; Group membership not reflected, or other background tasks stuck&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker compose restart worker&lt;br /&gt;
docker logs worker --tail=50&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Database corruption ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; PostgreSQL won&#039;t start, or &amp;quot;ERROR: relation does not exist&amp;quot;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039;&lt;br /&gt;
* Try recovery from backup (Disaster Mode B)&lt;br /&gt;
* If no backup: full reset (Disaster Mode A) — all data lost&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Links &amp;amp; References ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Authentik Docs:&#039;&#039;&#039; https://goauthentik.io/docs/&lt;br /&gt;
* &#039;&#039;&#039;Authentik Admin UI:&#039;&#039;&#039; https://key.wilsoz.com/if/admin/&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL Docs:&#039;&#039;&#039; https://www.postgresql.org/docs/16/&lt;br /&gt;
* &#039;&#039;&#039;OAuth 2.0 Spec:&#039;&#039;&#039; https://datatracker.ietf.org/doc/html/rfc6749&lt;br /&gt;
* &#039;&#039;&#039;OpenID Connect Spec:&#039;&#039;&#039; https://openid.net/connect/&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Related Services ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;OpenBao&#039;&#039;&#039; (secrets store): &amp;lt;code&amp;gt;/library/openbao-app/&amp;lt;/code&amp;gt; — stores API tokens&lt;br /&gt;
* &#039;&#039;&#039;Stalwart&#039;&#039;&#039; (mail server): Uses LDAP for user lookups&lt;br /&gt;
* &#039;&#039;&#039;Nextcloud&#039;&#039;&#039; (file sync): Uses LDAP for CalDAV/CardDAV&lt;br /&gt;
* &#039;&#039;&#039;n8n&#039;&#039;&#039; (automation): Uses OAuth for form access control&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL&#039;&#039;&#039; (database): Internal to Authentik&lt;br /&gt;
* &#039;&#039;&#039;Nginx&#039;&#039;&#039; (reverse proxy): VPS nginx forwards key.wilsoz.com traffic here&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/Stalwart&amp;diff=28</id>
		<title>Services/Stalwart</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/Stalwart&amp;diff=28"/>
		<updated>2026-07-05T18:23:41Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Recreated after purging exposed-credential revision&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from services/STALWART.md. Credential values redacted per Rule 0 (see OpenBao secret/stalwart) — the source repo file still has plaintext values and needs a separate scrub + rotation, flagged separately.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Stalwart — Mail Server (SMTP/IMAP/LDAP/Sieve/JMAP) =&lt;br /&gt;
&lt;br /&gt;
Complete setup &amp;amp; operations guide for Stalwart mail server with LDAP authentication, relay routing, and getmail integration.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Criticality:&#039;&#039;&#039; &#039;&#039;&#039;HIGH — Mail infrastructure.&#039;&#039;&#039; Losing Stalwart means loss of email receive/send capability. Unlike Authentik (SSO), Stalwart failure doesn&#039;t break other services, but email is critical infrastructure. Requires careful recovery.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Stalwart&#039;&#039;&#039; is a modern open-source mail server that provides:&lt;br /&gt;
* &#039;&#039;&#039;SMTP&#039;&#039;&#039; (port 25 inbound, 465/587 outbound) — message transport&lt;br /&gt;
* &#039;&#039;&#039;IMAP&#039;&#039;&#039; (port 143/993) — message retrieval with TLS&lt;br /&gt;
* &#039;&#039;&#039;POP3&#039;&#039;&#039; (port 110/995) — legacy message retrieval&lt;br /&gt;
* &#039;&#039;&#039;Sieve&#039;&#039;&#039; (port 4190/ManageSieve) — mail filtering rules&lt;br /&gt;
* &#039;&#039;&#039;JMAP&#039;&#039;&#039; (port 443) — modern mail protocol (experimental)&lt;br /&gt;
* &#039;&#039;&#039;LDAP&#039;&#039;&#039; (LDAP passthrough to Authentik at port 389) — user authentication&lt;br /&gt;
* &#039;&#039;&#039;Web Admin&#039;&#039;&#039; (port 8181 internal HTTP API) — configuration and management&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current setup:&#039;&#039;&#039;&lt;br /&gt;
* Primary domain: &amp;lt;code&amp;gt;wilsoz.com&amp;lt;/code&amp;gt; — marcus@wilsoz.com is main account&lt;br /&gt;
* Secondary domain: &amp;lt;code&amp;gt;craniumslows.com&amp;lt;/code&amp;gt; — accounts federated to wilsoz via getmail&lt;br /&gt;
* Inbound mail sources: Fastmail, MXRoute, Gmail, iCloud, SDF (fetched via getmail)&lt;br /&gt;
* Outbound relay routes: Fastmail (wilsoz.com), MXRoute (craniumslows.com), Gmail, SDF (via proxy)&lt;br /&gt;
* Mail clients: eM Client (IMAP/SMTP), SnappyMail (web), Thunderbird (config compatible)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Version:&#039;&#039;&#039; Stalwart latest (updates automatically via Watchtower)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Access:&#039;&#039;&#039; &lt;br /&gt;
* Internal admin API: &amp;lt;code&amp;gt;http://localhost:8181/api&amp;lt;/code&amp;gt;&lt;br /&gt;
* ManageSieve: &amp;lt;code&amp;gt;localhost:4190&amp;lt;/code&amp;gt; (STARTTLS required)&lt;br /&gt;
* IMAP: &amp;lt;code&amp;gt;localhost:993&amp;lt;/code&amp;gt; (TLS) or &amp;lt;code&amp;gt;mymail.wilsoz.com:993&amp;lt;/code&amp;gt; (external)&lt;br /&gt;
* SMTP: &amp;lt;code&amp;gt;localhost:465&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;mymail.wilsoz.com:465&amp;lt;/code&amp;gt; (external)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Service Information ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Property&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Compose file&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/stalwart-app/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Container name&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;stalwart-mail&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Image&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;stalwartlabs/stalwart:latest&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Network mode&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;host&amp;lt;/code&amp;gt; (direct network access, not bridged)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Restart policy&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;unless-stopped&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Data volume&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/stalwart-app/data/&amp;lt;/code&amp;gt; (contains &amp;lt;code&amp;gt;/etc/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/queue/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/store/&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;TLS certificates&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/etc/letsencrypt/live/mymail.wilsoz.com/&amp;lt;/code&amp;gt; (mounted readonly)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Admin credentials&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;admin:&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt;&amp;lt;/code&amp;gt; (plaintext password, hashed in config.toml)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Admin API port&#039;&#039;&#039;&lt;br /&gt;
| 8181 (internal only)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;SMTP port&#039;&#039;&#039;&lt;br /&gt;
| 25 (inbound), 465 (outbound implicit TLS), 587 (submission STARTTLS)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;IMAP ports&#039;&#039;&#039;&lt;br /&gt;
| 143 (STARTTLS), 993 (implicit TLS)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;POP3 ports&#039;&#039;&#039;&lt;br /&gt;
| 110 (STARTTLS), 995 (implicit TLS)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Sieve port&#039;&#039;&#039;&lt;br /&gt;
| 4190 (ManageSieve, STARTTLS then PLAIN auth)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;JMAP port&#039;&#039;&#039;&lt;br /&gt;
| 443 (via reverse proxy, not directly exposed)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;LDAP passthrough&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;localhost:389&amp;lt;/code&amp;gt; (to Authentik LDAP outpost)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Ports Summary ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Port&lt;br /&gt;
! Protocol&lt;br /&gt;
! Direction&lt;br /&gt;
! Purpose&lt;br /&gt;
! TLS&lt;br /&gt;
! Notes&lt;br /&gt;
|-&lt;br /&gt;
| 25&lt;br /&gt;
| SMTP&lt;br /&gt;
| Inbound&lt;br /&gt;
| Receive mail from internet&lt;br /&gt;
| None&lt;br /&gt;
| MX record points here&lt;br /&gt;
|-&lt;br /&gt;
| 465&lt;br /&gt;
| SMTPS&lt;br /&gt;
| Outbound&lt;br /&gt;
| Send via Fastmail/MXRoute&lt;br /&gt;
| Implicit&lt;br /&gt;
| ISP allows only 465, blocks 25/587&lt;br /&gt;
|-&lt;br /&gt;
| 587&lt;br /&gt;
| SMTP&lt;br /&gt;
| Outbound&lt;br /&gt;
| Send via some relays&lt;br /&gt;
| STARTTLS&lt;br /&gt;
| ISP blocks this, use 465 instead&lt;br /&gt;
|-&lt;br /&gt;
| 143&lt;br /&gt;
| IMAP&lt;br /&gt;
| Internal&lt;br /&gt;
| Retrieve mail (legacy)&lt;br /&gt;
| STARTTLS&lt;br /&gt;
| Plain AUTH disabled, must TLS first&lt;br /&gt;
|-&lt;br /&gt;
| 993&lt;br /&gt;
| IMAPS&lt;br /&gt;
| Internal&lt;br /&gt;
| Retrieve mail (standard)&lt;br /&gt;
| Implicit&lt;br /&gt;
| Recommended, supports admin impersonation&lt;br /&gt;
|-&lt;br /&gt;
| 110/995&lt;br /&gt;
| POP3/S&lt;br /&gt;
| Internal&lt;br /&gt;
| Legacy retrieval&lt;br /&gt;
| —&lt;br /&gt;
| Available but unused&lt;br /&gt;
|-&lt;br /&gt;
| 4190&lt;br /&gt;
| ManageSieve&lt;br /&gt;
| Internal&lt;br /&gt;
| Upload sieve rules&lt;br /&gt;
| STARTTLS&lt;br /&gt;
| Auth only advertised post-TLS&lt;br /&gt;
|-&lt;br /&gt;
| 8181&lt;br /&gt;
| HTTP API&lt;br /&gt;
| Internal&lt;br /&gt;
| Admin config/settings&lt;br /&gt;
| None&lt;br /&gt;
| Internal only, no TLS&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Initial Setup from Scratch ==&lt;br /&gt;
&lt;br /&gt;
=== Prerequisites ===&lt;br /&gt;
&lt;br /&gt;
* Docker and docker-compose installed&lt;br /&gt;
* LDAP access to Authentik LDAP outpost (must be running first)&lt;br /&gt;
* TLS certificate for &amp;lt;code&amp;gt;mymail.wilsoz.com&amp;lt;/code&amp;gt; at &amp;lt;code&amp;gt;/etc/letsencrypt/live/mymail.wilsoz.com/&amp;lt;/code&amp;gt;&lt;br /&gt;
* DNS MX records configured to point to Stronghold&lt;br /&gt;
* Outbound SMTP to external relays configured (Fastmail, MXRoute, etc.)&lt;br /&gt;
&lt;br /&gt;
=== 1. Create Directory &amp;amp; Files ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p /library/stalwart-app/data&lt;br /&gt;
cd /library/stalwart-app&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Create docker-compose.yml ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;yaml&amp;quot;&amp;gt;&lt;br /&gt;
version: &amp;quot;3.8&amp;quot;&lt;br /&gt;
&lt;br /&gt;
services:&lt;br /&gt;
  stalwart:&lt;br /&gt;
    image: stalwartlabs/stalwart:latest&lt;br /&gt;
    container_name: stalwart-mail&lt;br /&gt;
    hostname: stronghold&lt;br /&gt;
    network_mode: host&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    volumes:&lt;br /&gt;
      - ./data:/opt/stalwart&lt;br /&gt;
      - /etc/letsencrypt:/opt/letsencrypt:ro&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. Create .env File ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Admin credentials (plaintext; password gets hashed in config.toml)&lt;br /&gt;
STALWART_ADMIN_USER=admin&lt;br /&gt;
STALWART_ADMIN_PASSWORD=&amp;lt;generate-strong-password-20-chars&amp;gt;&lt;br /&gt;
&lt;br /&gt;
# LDAP (Authentik LDAP outpost)&lt;br /&gt;
LDAP_HOST=localhost&lt;br /&gt;
LDAP_PORT=389&lt;br /&gt;
LDAP_BASE_DN=&amp;quot;dc=ldap,dc=goauthentik,dc=io&amp;quot;&lt;br /&gt;
LDAP_USER_BASE=&amp;quot;ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot;&lt;br /&gt;
LDAP_SERVICE_ACCOUNT=&amp;quot;cn=stalwart-svc,ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot;&lt;br /&gt;
LDAP_SERVICE_PASSWORD=&amp;lt;from-Authentik-stalwart-svc-user&amp;gt;&lt;br /&gt;
&lt;br /&gt;
# Mail domains&lt;br /&gt;
PRIMARY_DOMAIN=wilsoz.com&lt;br /&gt;
SECONDARY_DOMAIN=craniumslows.com&lt;br /&gt;
&lt;br /&gt;
# TLS certificate (mounted from host)&lt;br /&gt;
TLS_CERT_PATH=/opt/letsencrypt/live/mymail.wilsoz.com/fullchain.pem&lt;br /&gt;
TLS_KEY_PATH=/opt/letsencrypt/live/mymail.wilsoz.com/privkey.pem&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4. Create config.toml (Bootstrap Configuration) ===&lt;br /&gt;
&lt;br /&gt;
This is a complex TOML file. Start with a minimal bootstrap config containing only:&lt;br /&gt;
* Listener (SMTP, IMAP, POP3, Sieve ports)&lt;br /&gt;
* Storage (database and message store)&lt;br /&gt;
* TLS certificate paths&lt;br /&gt;
* Authentication (LDAP + fallback admin)&lt;br /&gt;
* Admin API&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Key principle:&#039;&#039;&#039; All persistent settings (relay routes, routing strategy, rate limits) must be configured via the Admin HTTP API at port 8181, NOT in config.toml. The reason: &amp;lt;code&amp;gt;GET /api/reload&amp;lt;/code&amp;gt; rewrites config.toml in flat key=value format, stripping all TOML sections.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;toml&amp;quot;&amp;gt;&lt;br /&gt;
# Minimal bootstrap config.toml&lt;br /&gt;
[server]&lt;br /&gt;
hostname = &amp;quot;stronghold&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Listeners&lt;br /&gt;
[[listeners.smtp]]&lt;br /&gt;
bind = &amp;quot;0.0.0.0:25&amp;quot;&lt;br /&gt;
protocol = &amp;quot;smtp&amp;quot;&lt;br /&gt;
&lt;br /&gt;
[[listeners.smtp]]&lt;br /&gt;
bind = &amp;quot;0.0.0.0:465&amp;quot;&lt;br /&gt;
protocol = &amp;quot;smtp&amp;quot;&lt;br /&gt;
tls.implicit = true&lt;br /&gt;
&lt;br /&gt;
[[listeners.smtp]]&lt;br /&gt;
bind = &amp;quot;0.0.0.0:587&amp;quot;&lt;br /&gt;
protocol = &amp;quot;smtp&amp;quot;&lt;br /&gt;
tls.starttls = true&lt;br /&gt;
&lt;br /&gt;
[[listeners.imap]]&lt;br /&gt;
bind = &amp;quot;0.0.0.0:143&amp;quot;&lt;br /&gt;
protocol = &amp;quot;imap&amp;quot;&lt;br /&gt;
tls.starttls = true&lt;br /&gt;
&lt;br /&gt;
[[listeners.imap]]&lt;br /&gt;
bind = &amp;quot;0.0.0.0:993&amp;quot;&lt;br /&gt;
protocol = &amp;quot;imap&amp;quot;&lt;br /&gt;
tls.implicit = true&lt;br /&gt;
&lt;br /&gt;
[[listeners.pop3]]&lt;br /&gt;
bind = &amp;quot;0.0.0.0:110&amp;quot;&lt;br /&gt;
protocol = &amp;quot;pop3&amp;quot;&lt;br /&gt;
tls.starttls = true&lt;br /&gt;
&lt;br /&gt;
[[listeners.pop3]]&lt;br /&gt;
bind = &amp;quot;0.0.0.0:995&amp;quot;&lt;br /&gt;
protocol = &amp;quot;pop3&amp;quot;&lt;br /&gt;
tls.implicit = true&lt;br /&gt;
&lt;br /&gt;
[[listeners.managesieve]]&lt;br /&gt;
bind = &amp;quot;0.0.0.0:4190&amp;quot;&lt;br /&gt;
protocol = &amp;quot;sieve&amp;quot;&lt;br /&gt;
tls.starttls = true&lt;br /&gt;
&lt;br /&gt;
[[listeners.http]]&lt;br /&gt;
bind = &amp;quot;127.0.0.1:8181&amp;quot;&lt;br /&gt;
protocol = &amp;quot;http&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# TLS&lt;br /&gt;
[certificate.default]&lt;br /&gt;
cert = &amp;quot;/opt/letsencrypt/live/mymail.wilsoz.com/fullchain.pem&amp;quot;&lt;br /&gt;
private-key = &amp;quot;/opt/letsencrypt/live/mymail.wilsoz.com/privkey.pem&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Storage (SQLite database + file-based message store)&lt;br /&gt;
[storage.data]&lt;br /&gt;
type = &amp;quot;sqlite&amp;quot;&lt;br /&gt;
path = &amp;quot;/opt/stalwart/store/data.sqlite&amp;quot;&lt;br /&gt;
&lt;br /&gt;
[storage.fts]&lt;br /&gt;
type = &amp;quot;sqlite&amp;quot;&lt;br /&gt;
path = &amp;quot;/opt/stalwart/store/fts.sqlite&amp;quot;&lt;br /&gt;
&lt;br /&gt;
[storage.directory]&lt;br /&gt;
type = &amp;quot;sqlite&amp;quot;&lt;br /&gt;
path = &amp;quot;/opt/stalwart/store/directory.sqlite&amp;quot;&lt;br /&gt;
&lt;br /&gt;
[storage.lookup]&lt;br /&gt;
type = &amp;quot;sqlite&amp;quot;&lt;br /&gt;
path = &amp;quot;/opt/stalwart/store/lookup.sqlite&amp;quot;&lt;br /&gt;
&lt;br /&gt;
[storage.blob]&lt;br /&gt;
type = &amp;quot;fs&amp;quot;&lt;br /&gt;
path = &amp;quot;/opt/stalwart/store/blobs&amp;quot;&lt;br /&gt;
&lt;br /&gt;
[storage.queue]&lt;br /&gt;
type = &amp;quot;fs&amp;quot;&lt;br /&gt;
path = &amp;quot;/opt/stalwart/queue&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Authentication&lt;br /&gt;
[auth]&lt;br /&gt;
directory = &amp;quot;ldap&amp;quot;&lt;br /&gt;
&lt;br /&gt;
[[directory.ldap]]&lt;br /&gt;
name = &amp;quot;ldap&amp;quot;&lt;br /&gt;
address = &amp;quot;localhost&amp;quot;&lt;br /&gt;
port = 389&lt;br /&gt;
base-dn = &amp;quot;dc=ldap,dc=goauthentik,dc=io&amp;quot;&lt;br /&gt;
user-base-dn = &amp;quot;ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot;&lt;br /&gt;
bind-dn = &amp;quot;cn=stalwart-svc,ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot;&lt;br /&gt;
bind-password = &amp;quot;&amp;lt;from OpenBao: secret/stalwart/ldap-bind-password&amp;gt;&amp;quot;  # rotated 2026-05-18&lt;br /&gt;
filter = &amp;quot;(&amp;amp;(objectClass=posixAccount)(cn=?))&amp;quot;&lt;br /&gt;
mail-attribute = &amp;quot;mail&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Fallback admin (in case LDAP fails)&lt;br /&gt;
[authentication.fallback-admin]&lt;br /&gt;
user = &amp;quot;admin&amp;quot;&lt;br /&gt;
password = &amp;quot;$6$HFsr55uFqNc31Mw8$rLv/wtAzFCr0yNfY.../...hashed-password&amp;quot;  # Will be generated on first startup&lt;br /&gt;
&lt;br /&gt;
# Domains (minimal)&lt;br /&gt;
[[domains]]&lt;br /&gt;
name = &amp;quot;wilsoz.com&amp;quot;&lt;br /&gt;
&lt;br /&gt;
[[domains]]&lt;br /&gt;
name = &amp;quot;craniumslows.com&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Admin API&lt;br /&gt;
[server.admin]&lt;br /&gt;
bind = &amp;quot;127.0.0.1&amp;quot;&lt;br /&gt;
port = 8181&lt;br /&gt;
&lt;br /&gt;
# Logging&lt;br /&gt;
[tracer.log]&lt;br /&gt;
level = &amp;quot;info&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5. Start Stalwart ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/stalwart-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Wait for startup (~10 seconds)&lt;br /&gt;
sleep 10&lt;br /&gt;
&lt;br /&gt;
# Check logs&lt;br /&gt;
docker logs stalwart-mail | tail -20&lt;br /&gt;
&lt;br /&gt;
# Should see: &amp;quot;Stalwart Mail Server started&amp;quot; and port listeners binding&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6. Configure Via Admin API (port 8181) ===&lt;br /&gt;
&lt;br /&gt;
After startup, all configuration happens via the HTTP API. See &amp;quot;Admin API&amp;quot; section below for how to:&lt;br /&gt;
* Create mail accounts (principals)&lt;br /&gt;
* Configure relay routes (outbound SMTP)&lt;br /&gt;
* Configure routing strategy (which route to use for each sender)&lt;br /&gt;
* Upload sieve rules&lt;br /&gt;
* Set spam filter thresholds&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example: Create a mail account&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -u admin:&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt; -X POST \&lt;br /&gt;
  http://localhost:8181/api/principal \&lt;br /&gt;
  -H &amp;quot;Content-Type: application/json&amp;quot; \&lt;br /&gt;
  -d &#039;{&lt;br /&gt;
    &amp;quot;type&amp;quot;: &amp;quot;individual&amp;quot;,&lt;br /&gt;
    &amp;quot;name&amp;quot;: &amp;quot;marcus@wilsoz.com&amp;quot;,&lt;br /&gt;
    &amp;quot;emails&amp;quot;: [&amp;quot;marcus@wilsoz.com&amp;quot;],&lt;br /&gt;
    &amp;quot;secrets&amp;quot;: [&amp;quot;marcus-password&amp;quot;]&lt;br /&gt;
  }&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 7. Configure LDAP Sync ===&lt;br /&gt;
&lt;br /&gt;
Stalwart can authenticate against Authentik LDAP, but users must exist. The simplest approach:&lt;br /&gt;
* Create users in Authentik → Directory → Users&lt;br /&gt;
* Add them to &amp;lt;code&amp;gt;wilsoz-email&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;craniumslows-email&amp;lt;/code&amp;gt; groups&lt;br /&gt;
* Stalwart reads LDAP attributes: &amp;lt;code&amp;gt;mail&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;mailAddresses&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;memberOf&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Test LDAP:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ldapsearch -x -H ldap://localhost:389 \&lt;br /&gt;
  -D &amp;quot;cn=stalwart-svc,ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; \&lt;br /&gt;
  -w &amp;quot;&amp;lt;rotated 2026-05-18; see OpenBao secret/stalwart/ldap-bind-password&amp;gt;&amp;quot; \&lt;br /&gt;
  -b &amp;quot;ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; \&lt;br /&gt;
  &amp;quot;cn=marcus&amp;quot; mail mailAddresses&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 8. Set Up TLS Certificate Renewal ===&lt;br /&gt;
&lt;br /&gt;
Stalwart uses certificates at &amp;lt;code&amp;gt;/opt/letsencrypt/live/mymail.wilsoz.com/&amp;lt;/code&amp;gt;. When certificates renew (certbot, daily check):&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Create renewal hook&#039;&#039;&#039; to reload Stalwart:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo nano /etc/letsencrypt/renewal-hooks/deploy/stalwart-reload.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
#!/bin/bash&lt;br /&gt;
# Reload Stalwart config after certificate renewal&lt;br /&gt;
docker exec stalwart-mail curl -u admin:&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt; http://localhost:8181/api/reload&lt;br /&gt;
echo &amp;quot;Stalwart reloaded on $(date)&amp;quot; &amp;gt;&amp;gt; /var/log/stalwart-reload.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/stalwart-reload.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Test renewal:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo certbot renew --dry-run&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 9. Configure VPS Reverse Proxy (if external access needed) ===&lt;br /&gt;
&lt;br /&gt;
On VPS, forward &amp;lt;code&amp;gt;mymail.wilsoz.com&amp;lt;/code&amp;gt; to Stronghold. Example nginx config:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;nginx&amp;quot;&amp;gt;&lt;br /&gt;
# IMAP/SMTP passthrough (not HTTP)&lt;br /&gt;
# This requires HAProxy or similar (not standard nginx)&lt;br /&gt;
&lt;br /&gt;
# Alternatively, use Stronghold&#039;s direct port exposure + firewall rules&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Actually, for mail protocols (SMTP/IMAP), most setups expose ports directly. Configure firewall rules on stronghold to allow:&lt;br /&gt;
* Port 25 (inbound SMTP) — public&lt;br /&gt;
* Port 465/587 (outbound) — already open by ISP&lt;br /&gt;
* Port 993 (IMAP, TLS) — expose to VPS or public&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disaster Mode A: Configuration Corruption ==&lt;br /&gt;
&lt;br /&gt;
=== Symptoms ===&lt;br /&gt;
&lt;br /&gt;
* Can&#039;t send/receive mail&lt;br /&gt;
* LDAP authentication failing&lt;br /&gt;
* Sieve rules not executing&lt;br /&gt;
* Relay routes returning errors&lt;br /&gt;
* Database corrupted (SQLite integrity errors)&lt;br /&gt;
* Admin credentials lost or forgotten&lt;br /&gt;
&lt;br /&gt;
=== Recovery Steps ===&lt;br /&gt;
&lt;br /&gt;
==== Option 1: Reset Configuration (Keep Mail Data) ====&lt;br /&gt;
&lt;br /&gt;
If only config is corrupted but mail data is intact:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/stalwart-app&lt;br /&gt;
&lt;br /&gt;
# Stop Stalwart&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Backup old config&lt;br /&gt;
cp data/etc/config.toml data/etc/config.toml.corrupted-$(date +%Y%m%d)&lt;br /&gt;
&lt;br /&gt;
# Copy clean bootstrap config (from Initial Setup step 4)&lt;br /&gt;
cp config.toml.bootstrap data/etc/config.toml&lt;br /&gt;
&lt;br /&gt;
# Start fresh&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Reconfigure via API (routes, strategy, sieve, etc.)&lt;br /&gt;
# See Admin API section below&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Option 2: Reset Admin Password (Via Direct API) ====&lt;br /&gt;
&lt;br /&gt;
If you can still reach the API but lost the admin password:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Edit config.toml directly to set new password&lt;br /&gt;
# Hash password using Stalwart&#039;s built-in hash&lt;br /&gt;
docker exec stalwart-mail stalwartd hash-password &amp;quot;NewPassword123&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Copy the hash output, edit config.toml:&lt;br /&gt;
[authentication.fallback-admin]&lt;br /&gt;
user = &amp;quot;admin&amp;quot;&lt;br /&gt;
password = &amp;quot;&amp;lt;paste-hash-here&amp;gt;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Restart&lt;br /&gt;
docker compose restart stalwart-mail&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Option 3: Full Reset (Lose All Configuration + Users) ====&lt;br /&gt;
&lt;br /&gt;
⚠️ This deletes everything except mail messages themselves.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/stalwart-app&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Delete all config and database&lt;br /&gt;
rm -rf data/etc/*&lt;br /&gt;
rm -rf data/store/*.sqlite&lt;br /&gt;
mkdir -p data/etc data/store/blobs data/queue&lt;br /&gt;
&lt;br /&gt;
# Copy bootstrap config&lt;br /&gt;
cp config.toml.bootstrap data/etc/config.toml&lt;br /&gt;
&lt;br /&gt;
# Delete Docker volume (optional, if vol became corrupted)&lt;br /&gt;
# docker volume rm stalwart-mail_data&lt;br /&gt;
&lt;br /&gt;
# Start from scratch&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Reconfigure everything:&lt;br /&gt;
# 1. Create relay routes&lt;br /&gt;
# 2. Create routing strategy&lt;br /&gt;
# 3. Create mail accounts&lt;br /&gt;
# 4. Upload sieve rules&lt;br /&gt;
# 5. Configure spam filters&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disaster Mode B: Drive Failure / Full Recovery ==&lt;br /&gt;
&lt;br /&gt;
=== If Backups Accessible ===&lt;br /&gt;
&lt;br /&gt;
Stalwart data is backed up:&lt;br /&gt;
# &#039;&#039;&#039;PostgreSQL dumps&#039;&#039;&#039; (if using PG backend) — at Hetzner &amp;lt;code&amp;gt;/dumps/stalwart-*.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
# &#039;&#039;&#039;Restic snapshots&#039;&#039;&#039; — at Hetzner &amp;lt;code&amp;gt;/data/stalwart/&amp;lt;/code&amp;gt;&lt;br /&gt;
# &#039;&#039;&#039;Timeshift snapshots&#039;&#039;&#039; — on &amp;lt;code&amp;gt;/raid1/timeshift/&amp;lt;/code&amp;gt; (if /var/lib/docker/volumes issue fixed)&lt;br /&gt;
&lt;br /&gt;
==== Recovery Steps (Using Restic): ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Deploy fresh Stalwart container (follow Initial Setup)&lt;br /&gt;
cd /library/stalwart-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 2. Stop Stalwart&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# 3. Restore mail data from Restic&lt;br /&gt;
cd /library/backup-stack&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/restore \&lt;br /&gt;
  --repo sftp:u540853@u540853.your-storagebox.de:./backups \&lt;br /&gt;
  --include &amp;quot;/data/stalwart&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# 4. Copy restored data to /library/stalwart-app&lt;br /&gt;
sudo cp -r /tmp/restore/data/stalwart/* /library/stalwart-app/data/&lt;br /&gt;
sudo chown -R 0:0 /library/stalwart-app/data/  # Stalwart runs as root in container&lt;br /&gt;
&lt;br /&gt;
# 5. Start Stalwart&lt;br /&gt;
cd /library/stalwart-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 6. Verify&lt;br /&gt;
docker logs stalwart-mail | grep -i &amp;quot;listener\|directory\|ldap&amp;quot;&lt;br /&gt;
curl -u admin:&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt; http://localhost:8181/api/principal | jq &#039;.principals | length&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== If Backups NOT Accessible ===&lt;br /&gt;
&lt;br /&gt;
⚠️ &#039;&#039;&#039;Mail data will be permanently lost.&#039;&#039;&#039; Recovery options:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Restore from getmail sources:&#039;&#039;&#039;&lt;br /&gt;
** Getmail configs at &amp;lt;code&amp;gt;/library/email/getmail/*.rc&amp;lt;/code&amp;gt; fetch from external sources&lt;br /&gt;
** If Hetzner or tape backup includes getmail state files, can re-fetch mail&lt;br /&gt;
** Mail will only go back to getmail fetch dates (~7 days, depending on retention)&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Request users to re-send important emails&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Restore from email client backups:&#039;&#039;&#039;&lt;br /&gt;
** If eM Client/Thunderbird cached mail, can export and reimport&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Full rebuild (minimal configuration, zero historical mail):&#039;&#039;&#039;&lt;br /&gt;
** Create new empty Stalwart instance&lt;br /&gt;
** Reconfigure routes, domains, users&lt;br /&gt;
** All historical mail is lost; only forward-going mail is received&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Data Locations ==&lt;br /&gt;
&lt;br /&gt;
=== Stalwart Data on Host ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
/library/stalwart-app/&lt;br /&gt;
├── docker-compose.yml       (service definition)&lt;br /&gt;
├── .env                     (LDAP passwords, admin user)&lt;br /&gt;
├── config.toml.bootstrap    (clean bootstrap config)&lt;br /&gt;
└── data/&lt;br /&gt;
    ├── etc/config.toml      (runtime config, auto-rewritten by /api/reload)&lt;br /&gt;
    ├── store/&lt;br /&gt;
    │   ├── data.sqlite      (principal directory, accounts, groups)&lt;br /&gt;
    │   ├── fts.sqlite       (full-text search index)&lt;br /&gt;
    │   ├── directory.sqlite (LDAP cache)&lt;br /&gt;
    │   ├── lookup.sqlite    (MX/DNS lookups)&lt;br /&gt;
    │   └── blobs/           (message attachments, large objects)&lt;br /&gt;
    └── queue/               (outbound mail queue)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Content Storage ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Messages themselves&#039;&#039;&#039; are stored differently based on storage backend:&lt;br /&gt;
* &#039;&#039;&#039;FileSystem (current):&#039;&#039;&#039; &amp;lt;code&amp;gt;/library/stalwart-app/data/store/blobs/&amp;lt;/code&amp;gt; — one file per message/attachment&lt;br /&gt;
* &#039;&#039;&#039;Database:&#039;&#039;&#039; Could be SQLite or PostgreSQL&lt;br /&gt;
&lt;br /&gt;
=== Backup Locations ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Hetzner Restic:&#039;&#039;&#039; &amp;lt;code&amp;gt;/data/stalwart/&amp;lt;/code&amp;gt; (daily snapshots)&lt;br /&gt;
&#039;&#039; &#039;&#039;&#039;Local Timeshift:&#039;&#039;&#039; &amp;lt;code&amp;gt;/raid1/timeshift/&#039;&#039;/library/stalwart-app/data/&amp;lt;/code&amp;gt; (if /var/lib/docker/volumes included)&lt;br /&gt;
* &#039;&#039;&#039;Home directory:&#039;&#039;&#039; Config snippets, scripts at &amp;lt;code&amp;gt;/home/mnw/Developer/SystemResiliency/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== What&#039;s Backed Up ===&lt;br /&gt;
&lt;br /&gt;
✅ All mail messages (blobs/)&lt;br /&gt;
✅ Account directory and settings (data.sqlite)&lt;br /&gt;
✅ LDAP cache (directory.sqlite)&lt;br /&gt;
✅ Outbound queue (queue/)&lt;br /&gt;
✅ Config.toml (auto-rewritten but restored)&lt;br /&gt;
&lt;br /&gt;
❌ &#039;&#039;&#039;NOT backed up by Timeshift:&#039;&#039;&#039; &amp;lt;code&amp;gt;/var/lib/docker/volumes&amp;lt;/code&amp;gt; (includes SQLite WAL files — &#039;&#039;&#039;known issue&#039;&#039;&#039;)&lt;br /&gt;
✅ Backed up by Restic: All /library/stalwart-app content&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Critical Gotchas ==&lt;br /&gt;
&lt;br /&gt;
=== 1. config.toml Rewriting Gotcha (CRITICAL) ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem:&#039;&#039;&#039; When you call &amp;lt;code&amp;gt;GET /api/reload&amp;lt;/code&amp;gt;, Stalwart rewrites config.toml in flat &amp;lt;code&amp;gt;key=value&amp;lt;/code&amp;gt; format, &#039;&#039;&#039;stripping all TOML sections.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;toml&amp;quot;&amp;gt;&lt;br /&gt;
# Original config.toml&lt;br /&gt;
[server.admin]&lt;br /&gt;
bind = &amp;quot;127.0.0.1&amp;quot;&lt;br /&gt;
port = 8181&lt;br /&gt;
&lt;br /&gt;
# After /api/reload&lt;br /&gt;
server.admin.bind = 127.0.0.1&lt;br /&gt;
server.admin.port = 8181&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Consequence:&#039;&#039;&#039; All relay routes, routing strategy, rate limits, spam settings created via API are LOST when rewriting happens.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Solution:&#039;&#039;&#039; &lt;br /&gt;
* &#039;&#039;&#039;Only store bootstrap/listener/storage/auth settings in config.toml&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;All persistent settings (routes, strategy, spam) MUST live in the database via &amp;lt;code&amp;gt;/api/settings&amp;lt;/code&amp;gt; API&#039;&#039;&#039;&lt;br /&gt;
* Never call &amp;lt;code&amp;gt;/api/reload&amp;lt;/code&amp;gt; unless you&#039;re OK with losing API-configured settings&lt;br /&gt;
* If you accidentally called it, restore from backup immediately&lt;br /&gt;
&lt;br /&gt;
=== 2. Routes Live Under &amp;lt;code&amp;gt;queue.route.*&amp;lt;/code&amp;gt; Namespace (CRITICAL) ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Wrong:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings/list?prefix=route.&lt;br /&gt;
# Returns 0 results — namespace is wrong&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Right:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings/list?prefix=queue.route.&lt;br /&gt;
# Returns all routes&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Lesson:&#039;&#039;&#039; Always use full namespace path &amp;lt;code&amp;gt;queue.route.NAME&amp;lt;/code&amp;gt; not &amp;lt;code&amp;gt;route.NAME&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
=== 3. Settings API Format — Stalwart 0.15.4 Specific ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;assert_empty field is REQUIRED (and must be snake_case, NOT camelCase):&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# WRONG — camelCase&lt;br /&gt;
-d &#039;[{&amp;quot;type&amp;quot;:&amp;quot;insert&amp;quot;,&amp;quot;prefix&amp;quot;:&amp;quot;queue.route.fastmail&amp;quot;,&amp;quot;assertEmpty&amp;quot;:false,...}]&#039;&lt;br /&gt;
# Returns: 400 JSON deserialization failed&lt;br /&gt;
&lt;br /&gt;
# RIGHT — snake_case&lt;br /&gt;
-d &#039;[{&amp;quot;type&amp;quot;:&amp;quot;insert&amp;quot;,&amp;quot;prefix&amp;quot;:&amp;quot;queue.route.fastmail&amp;quot;,&amp;quot;assert_empty&amp;quot;:false,...}]&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Quirk:&#039;&#039;&#039; The enum &amp;lt;code&amp;gt;type&amp;lt;/code&amp;gt; values are camelCase (&amp;lt;code&amp;gt;insert&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;delete&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;clear&amp;lt;/code&amp;gt;), but the struct fields use snake_case due to Serde serialization rules. This is &#039;&#039;&#039;version-specific&#039;&#039;&#039; to 0.15.4. Post-0.16.0 may change.&lt;br /&gt;
&lt;br /&gt;
=== 4. ISP Blocks Port 25 AND 587 Outbound — Port 465 Only ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fact:&#039;&#039;&#039; Your ISP (likely Comcast or similar) blocks:&lt;br /&gt;
* ✅ Inbound port 25 (you can receive)&lt;br /&gt;
* ❌ Outbound port 25 (can&#039;t relay)&lt;br /&gt;
* ❌ Outbound port 587 (can&#039;t relay, STARTTLS submission)&lt;br /&gt;
* ✅ Outbound port 465 (implicit TLS works)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Configuration:&#039;&#039;&#039; All outbound relay routes must use:&lt;br /&gt;
* &amp;lt;code&amp;gt;port = 465&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;tls.implicit = true&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;protocol = &amp;quot;smtp&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example (Fastmail relay):&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings -H &amp;quot;Content-Type: application/json&amp;quot; \&lt;br /&gt;
  -d &#039;[{&amp;quot;type&amp;quot;:&amp;quot;insert&amp;quot;,&amp;quot;prefix&amp;quot;:&amp;quot;queue.route.fastmail-relay&amp;quot;,&amp;quot;assert_empty&amp;quot;:false,&amp;quot;values&amp;quot;:[&lt;br /&gt;
    [&amp;quot;type&amp;quot;,&amp;quot;relay&amp;quot;],&lt;br /&gt;
    [&amp;quot;address&amp;quot;,&amp;quot;smtp.fastmail.com&amp;quot;],&lt;br /&gt;
    [&amp;quot;port&amp;quot;,&amp;quot;465&amp;quot;],&lt;br /&gt;
    [&amp;quot;tls.implicit&amp;quot;,&amp;quot;true&amp;quot;],&lt;br /&gt;
    [&amp;quot;auth.username&amp;quot;,&amp;quot;marcus@wilsoz.com&amp;quot;],&lt;br /&gt;
    [&amp;quot;auth.secret&amp;quot;,&amp;quot;&amp;lt;fastmail-app-password&amp;gt;&amp;quot;]&lt;br /&gt;
  ]}]&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5. SDF Relay Requires VPS Python Proxy (SDF CRAM-MD5 auth) ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem:&#039;&#039;&#039; SDF mail server only supports CRAM-MD5 authentication, which Stalwart doesn&#039;t implement.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Solution:&#039;&#039;&#039; Python proxy on VPS (&amp;lt;code&amp;gt;/opt/sdf-smtp-proxy.py&amp;lt;/code&amp;gt;, systemd: &amp;lt;code&amp;gt;sdf-smtp-proxy.service&amp;lt;/code&amp;gt;)&lt;br /&gt;
* Listens on Tailscale IP &amp;lt;code&amp;gt;100.67.169.50:2587&amp;lt;/code&amp;gt; (no auth required)&lt;br /&gt;
* Relays to &amp;lt;code&amp;gt;mx.sdf.org:587&amp;lt;/code&amp;gt; with CRAM-MD5 auth&lt;br /&gt;
* Credentials stored in VPS &amp;lt;code&amp;gt;/opt/secrets.env&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Stalwart config for SDF:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Set /etc/hosts mapping on stronghold&lt;br /&gt;
100.67.169.50 sdf-relay.internal&lt;br /&gt;
&lt;br /&gt;
# Then add route&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings -H &amp;quot;Content-Type: application/json&amp;quot; \&lt;br /&gt;
  -d &#039;[{&amp;quot;type&amp;quot;:&amp;quot;insert&amp;quot;,&amp;quot;prefix&amp;quot;:&amp;quot;queue.route.sdf-relay&amp;quot;,&amp;quot;assert_empty&amp;quot;:false,&amp;quot;values&amp;quot;:[&lt;br /&gt;
    [&amp;quot;type&amp;quot;,&amp;quot;relay&amp;quot;],&lt;br /&gt;
    [&amp;quot;address&amp;quot;,&amp;quot;sdf-relay.internal&amp;quot;],&lt;br /&gt;
    [&amp;quot;port&amp;quot;,&amp;quot;2587&amp;quot;],&lt;br /&gt;
    [&amp;quot;tls.implicit&amp;quot;,&amp;quot;false&amp;quot;],&lt;br /&gt;
    [&amp;quot;tls.starttls&amp;quot;,&amp;quot;true&amp;quot;]&lt;br /&gt;
  ]}]&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Also, &#039;&#039;&#039;config.toml must set &amp;lt;code&amp;gt;resolver.type = &amp;quot;system&amp;quot;&amp;lt;/code&amp;gt;&#039;&#039;&#039; (not &amp;quot;default&amp;quot;) so it reads &amp;lt;code&amp;gt;/etc/hosts&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;toml&amp;quot;&amp;gt;&lt;br /&gt;
[resolver]&lt;br /&gt;
type = &amp;quot;system&amp;quot;  # NOT &amp;quot;default&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6. Routing Strategy: Local Recipients FIRST (Rule 0000) ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem:&#039;&#039;&#039; If rule 0000 doesn&#039;t check for local recipients, mail to &amp;lt;code&amp;gt;marcus@wilsoz.com&amp;lt;/code&amp;gt; gets sent through an external relay and fails.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Correct strategy (rules 0000-0002):&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings -H &amp;quot;Content-Type: application/json&amp;quot; \&lt;br /&gt;
  -d &#039;[{&amp;quot;type&amp;quot;:&amp;quot;insert&amp;quot;,&amp;quot;prefix&amp;quot;:&amp;quot;queue.strategy.route&amp;quot;,&amp;quot;assert_empty&amp;quot;:false,&amp;quot;values&amp;quot;:[&lt;br /&gt;
    [&amp;quot;0000.if&amp;quot;,&amp;quot;is_local_domain(&#039;\&#039;\&#039;\&#039;&#039;, rcpt_domain)&amp;quot;],&lt;br /&gt;
    [&amp;quot;0000.then&amp;quot;,&amp;quot;&#039;\&#039;&#039;local&#039;\&#039;&#039;&amp;quot;],&lt;br /&gt;
    [&amp;quot;0001.if&amp;quot;,&amp;quot;sender_domain == &#039;\&#039;&#039;wilsoz.com&#039;\&#039;&#039;&amp;quot;],&lt;br /&gt;
    [&amp;quot;0001.then&amp;quot;,&amp;quot;&#039;\&#039;&#039;wilsoz-fastmail&#039;\&#039;&#039;&amp;quot;],&lt;br /&gt;
    [&amp;quot;0002.else&amp;quot;,&amp;quot;&#039;\&#039;&#039;mx&#039;\&#039;&#039;&amp;quot;]&lt;br /&gt;
  ]}]&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Lesson:&#039;&#039;&#039; ALWAYS put local-domain check as rule 0000 (executed first).&lt;br /&gt;
&lt;br /&gt;
=== 7. LDAP Authentication Requires STARTTLS on Port 143 ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Wrong:&#039;&#039;&#039; Try to use &amp;lt;code&amp;gt;LOGIN&amp;lt;/code&amp;gt; auth on plain port 143&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
imap.login(&#039;marcus&#039;, &#039;password&#039;)  # Port 143&lt;br /&gt;
# Returns: AUTHENTICATIONFAILED&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Right:&#039;&#039;&#039; Use STARTTLS on port 143, or implicit TLS on port 993&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
imap.starttls()  # Upgrade to TLS&lt;br /&gt;
imap.login(&#039;marcus&#039;, &#039;password&#039;)  # Now LOGIN is available&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
This is a &#039;&#039;&#039;Stalwart security feature&#039;&#039;&#039; — LOGIN auth is only advertised after TLS negotiation (STARTTLS or implicit).&lt;br /&gt;
&lt;br /&gt;
=== 8. ManageSieve (Sieve Rules) Authentication Quirk ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem:&#039;&#039;&#039; Python &amp;lt;code&amp;gt;managesieve&amp;lt;/code&amp;gt; library calls &amp;lt;code&amp;gt;login()&amp;lt;/code&amp;gt; before &amp;lt;code&amp;gt;starttls()&amp;lt;/code&amp;gt;. After &amp;lt;code&amp;gt;starttls()&amp;lt;/code&amp;gt;, capabilities change to include &amp;lt;code&amp;gt;PLAIN&amp;lt;/code&amp;gt;. Library doesn&#039;t re-read capabilities, so login fails.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Solution:&#039;&#039;&#039; Use raw sockets (see &amp;quot;ManageSieve&amp;quot; section below in Operations).&lt;br /&gt;
&lt;br /&gt;
=== 9. Admin Impersonation via IMAP (May Not Work) ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Syntax:&#039;&#039;&#039; &amp;lt;code&amp;gt;admin*username@domain&amp;lt;/code&amp;gt; with admin password&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
imap.login(&#039;admin*marcus@wilsoz.com&#039;, &#039;&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt;&#039;)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Status:&#039;&#039;&#039; As of 2026-02-22, this returned AUTHENTICATIONFAILED. May need:&lt;br /&gt;
* &amp;lt;code&amp;gt;[imap] allow-plain-auth&amp;lt;/code&amp;gt; config setting enabled&lt;br /&gt;
* Or different impersonation mechanism&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Workaround:&#039;&#039;&#039; Use LDAP directly or set user passwords and log in normally.&lt;br /&gt;
&lt;br /&gt;
=== 10. TLS Certificate Path Must Exist at Startup ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem:&#039;&#039;&#039; If you mount &amp;lt;code&amp;gt;/etc/letsencrypt:/opt/letsencrypt:ro&amp;lt;/code&amp;gt; but cert files don&#039;t exist, Stalwart refuses to start.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Solution:&#039;&#039;&#039; Before starting, verify:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ls -l /etc/letsencrypt/live/mymail.wilsoz.com/&lt;br /&gt;
# fullchain.pem and privkey.pem must exist&lt;br /&gt;
&lt;br /&gt;
# If they don&#039;t, run certbot&lt;br /&gt;
sudo certbot certonly -d mymail.wilsoz.com --dns-cloudflare&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 11. Outbound Rate Limiter Example (MXRoute 300/hr) ===&lt;br /&gt;
&lt;br /&gt;
MXRoute has a rate limit of ~300 emails/hour. Configure in Stalwart:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings -H &amp;quot;Content-Type: application/json&amp;quot; \&lt;br /&gt;
  -d &#039;[{&amp;quot;type&amp;quot;:&amp;quot;insert&amp;quot;,&amp;quot;prefix&amp;quot;:&amp;quot;queue.limiter.outbound.mxroute&amp;quot;,&amp;quot;assert_empty&amp;quot;:false,&amp;quot;values&amp;quot;:[&lt;br /&gt;
    [&amp;quot;enable&amp;quot;,&amp;quot;true&amp;quot;],&lt;br /&gt;
    [&amp;quot;match&amp;quot;,&amp;quot;mx == &#039;\&#039;&#039;blizzard.mxrouting.net&#039;\&#039;&#039;&amp;quot;],&lt;br /&gt;
    [&amp;quot;key.0000&amp;quot;,&amp;quot;mx&amp;quot;],&lt;br /&gt;
    [&amp;quot;rate&amp;quot;,&amp;quot;300/1h&amp;quot;]&lt;br /&gt;
  ]}]&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 12. LDAP Password Storage (Plaintext in .env) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;LDAP_SERVICE_PASSWORD&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; is stored plaintext. It&#039;s the password for &amp;lt;code&amp;gt;cn=stalwart-svc&amp;lt;/code&amp;gt; (service account), which:&lt;br /&gt;
* Has limited permissions (ldap-search group only)&lt;br /&gt;
* Doesn&#039;t have login access&lt;br /&gt;
* Is used only for directory queries&lt;br /&gt;
&lt;br /&gt;
Store securely; if compromised, change in Authentik and update .env.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Verification Checklist ==&lt;br /&gt;
&lt;br /&gt;
After recovery, verify:&lt;br /&gt;
&lt;br /&gt;
* [ ] &#039;&#039;&#039;Container running:&#039;&#039;&#039; &amp;lt;code&amp;gt;docker ps | grep stalwart-mail&amp;lt;/code&amp;gt;&lt;br /&gt;
* [ ] &#039;&#039;&#039;Listeners bound:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker logs stalwart-mail | grep -i &amp;quot;listening\|bound&amp;quot;&lt;br /&gt;
  # Should show SMTP:25, SMTP:465, IMAP:143, IMAP:993, etc.&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Admin API responding:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  curl -u admin:&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt; http://localhost:8181/api/principal | jq &#039;.principals | length&#039;&lt;br /&gt;
  # Should return number of principals&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;LDAP connected:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker logs stalwart-mail | grep -i &amp;quot;ldap\|directory&amp;quot;&lt;br /&gt;
  # Should show successful connections, not errors&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;TLS certificate loaded:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker logs stalwart-mail | grep -i &amp;quot;certificate\|tls&amp;quot;&lt;br /&gt;
  # Should show cert paths loaded&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;IMAP login works:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  python3 -c &amp;quot;&lt;br /&gt;
  import imaplib, ssl&lt;br /&gt;
  ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE&lt;br /&gt;
  m = imaplib.IMAP4_SSL(&#039;localhost&#039;, 993)&lt;br /&gt;
  status, data = m.login(&#039;marcus&#039;, &#039;&amp;lt;password&amp;gt;&#039;)&lt;br /&gt;
  print(&#039;IMAP login:&#039;, &#039;OK&#039; if status == &#039;OK&#039; else f&#039;FAILED: {status}&#039;)&lt;br /&gt;
  m.close()&lt;br /&gt;
  &amp;quot;&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Relay routes configured:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  curl -s -u admin:&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt; http://localhost:8181/api/settings/list?prefix=queue.route | jq &#039;.settings | length&#039;&lt;br /&gt;
  # Should return &amp;gt; 0 (number of configured routes)&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Routing strategy configured:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  curl -s -u admin:&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt; http://localhost:8181/api/settings/list?prefix=queue.strategy | jq &#039;.settings | length&#039;&lt;br /&gt;
  # Should return &amp;gt; 0 (routing rules)&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Send test email:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  # Use eM Client or &amp;lt;code&amp;gt;swaks&amp;lt;/code&amp;gt; command-line tool&lt;br /&gt;
  swaks --to marcus@wilsoz.com --from test@wilsoz.com --server localhost:465 --tls&lt;br /&gt;
  # Should deliver without errors&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Receive test email:&#039;&#039;&#039;&lt;br /&gt;
** Send email from external account to marcus@wilsoz.com&lt;br /&gt;
** Check it appears in IMAP inbox&lt;br /&gt;
* [ ] &#039;&#039;&#039;Sieve rules executing:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  # If you have sieve rules, send test email and verify filtering works&lt;br /&gt;
  docker logs stalwart-mail | grep -i &amp;quot;sieve\|filter&amp;quot;&lt;br /&gt;
  ```&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== What Stalwart Depends On ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Authentik LDAP outpost&#039;&#039;&#039; — for user authentication (localhost:389)&lt;br /&gt;
* &#039;&#039;&#039;TLS certificate&#039;&#039;&#039; — from Let&#039;s Encrypt (/etc/letsencrypt)&lt;br /&gt;
* &#039;&#039;&#039;Network access to external relays:&#039;&#039;&#039;&lt;br /&gt;
** Fastmail: smtp.fastmail.com:465&lt;br /&gt;
** MXRoute: blizzard.mxrouting.net:465&lt;br /&gt;
** Gmail: smtp.gmail.com:465&lt;br /&gt;
** iCloud: smtp.mail.me.com:465&lt;br /&gt;
** SDF: sdf-relay.internal:2587 (via proxy)&lt;br /&gt;
* &#039;&#039;&#039;DNS resolution&#039;&#039;&#039; — for MX lookups and relay host resolution&lt;br /&gt;
* &#039;&#039;&#039;getmail&#039;&#039;&#039; — for inbound mail federation (craniumslows.com forwarding)&lt;br /&gt;
&lt;br /&gt;
=== What Depends On Stalwart ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;SnappyMail&#039;&#039;&#039; — web mail interface (frontend to Stalwart IMAP)&lt;br /&gt;
* &#039;&#039;&#039;eM Client&#039;&#039;&#039; — desktop mail client (uses IMAP/SMTP)&lt;br /&gt;
* &#039;&#039;&#039;getmail&#039;&#039;&#039; — mail federation from external accounts&lt;br /&gt;
* &#039;&#039;&#039;n8n workflows&#039;&#039;&#039; — email provisioning/deprovisioning&lt;br /&gt;
* &#039;&#039;&#039;Mail clients&#039;&#039;&#039; — Thunderbird, Outlook, etc.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Operations ==&lt;br /&gt;
&lt;br /&gt;
=== Start/Stop/Restart ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/stalwart-app&lt;br /&gt;
&lt;br /&gt;
# Start&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Stop&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Restart&lt;br /&gt;
docker compose restart stalwart-mail&lt;br /&gt;
&lt;br /&gt;
# Full restart (clears cache)&lt;br /&gt;
docker compose down &amp;amp;&amp;amp; sleep 2 &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View Logs ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Real-time logs&lt;br /&gt;
docker logs -f stalwart-mail&lt;br /&gt;
&lt;br /&gt;
# Last 50 lines&lt;br /&gt;
docker logs stalwart-mail --tail=50&lt;br /&gt;
&lt;br /&gt;
# Filter for errors&lt;br /&gt;
docker logs stalwart-mail 2&amp;gt;&amp;amp;1 | grep -i &amp;quot;error\|failed\|fatal&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Filter for specific component&lt;br /&gt;
docker logs stalwart-mail | grep -i &amp;quot;ldap\|imap\|smtp\|queue&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Admin API Queries ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ADMIN=&amp;quot;admin:&amp;lt;REDACTED - see OpenBao secret/stalwart&amp;gt;&amp;quot;&lt;br /&gt;
BASE=&amp;quot;http://localhost:8181/api&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# List all principals (accounts, groups)&lt;br /&gt;
curl -s -u &amp;quot;$ADMIN&amp;quot; &amp;quot;$BASE/principal&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Get specific principal&lt;br /&gt;
curl -s -u &amp;quot;$ADMIN&amp;quot; &amp;quot;$BASE/principal/marcus@wilsoz.com&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Reload config from disk (WARNING: see gotcha #1)&lt;br /&gt;
curl -s -u &amp;quot;$ADMIN&amp;quot; &amp;quot;$BASE/reload&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# List routes&lt;br /&gt;
curl -s -u &amp;quot;$ADMIN&amp;quot; &amp;quot;$BASE/settings/list?prefix=queue.route&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# List strategy rules&lt;br /&gt;
curl -s -u &amp;quot;$ADMIN&amp;quot; &amp;quot;$BASE/settings/list?prefix=queue.strategy&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# List spam settings&lt;br /&gt;
curl -s -u &amp;quot;$ADMIN&amp;quot; &amp;quot;$BASE/settings/list?prefix=spam-filter&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Count messages in queue&lt;br /&gt;
curl -s -u &amp;quot;$ADMIN&amp;quot; &amp;quot;$BASE/queue&amp;quot; | jq &#039;.messages | length&#039;&lt;br /&gt;
&lt;br /&gt;
# View a specific message in queue&lt;br /&gt;
curl -s -u &amp;quot;$ADMIN&amp;quot; &amp;quot;$BASE/queue/&amp;lt;message-id&amp;gt;&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Manage Sieve Rules (ManageSieve port 4190) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Upload sieve script (using helper script)&lt;br /&gt;
cd /home/mnw/Developer/SystemResiliency&lt;br /&gt;
python3 sieve/upload-sieve.py&lt;br /&gt;
&lt;br /&gt;
# Or use raw socket method (see Disaster Mode B section)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t Log In via IMAP ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Check LDAP:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ldapsearch -x -H ldap://localhost:389 \&lt;br /&gt;
  -D &amp;quot;cn=stalwart-svc,ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; \&lt;br /&gt;
  -w &amp;quot;&amp;lt;rotated 2026-05-18; see OpenBao secret/stalwart/ldap-bind-password&amp;gt;&amp;quot; \&lt;br /&gt;
  -b &amp;quot;ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; \&lt;br /&gt;
  &amp;quot;cn=marcus&amp;quot; mail&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;If LDAP works but IMAP login fails:&#039;&#039;&#039;&lt;br /&gt;
* Verify using port 993 (TLS) not 143 (plain)&lt;br /&gt;
* Or use STARTTLS on port 143&lt;br /&gt;
* Check if user is in &amp;lt;code&amp;gt;wilsoz-email&amp;lt;/code&amp;gt; group (not required for LDAP, but may be required by Stalwart)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Admin impersonation not working:&#039;&#039;&#039;&lt;br /&gt;
* Try normal login instead: &amp;lt;code&amp;gt;imap.login(&#039;marcus&#039;, &#039;&amp;lt;password&amp;gt;&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
* Impersonation may need config flag changes&lt;br /&gt;
&lt;br /&gt;
=== Relay Routes Not Working ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Debug:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check routes exist&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings/list?prefix=queue.route&lt;br /&gt;
&lt;br /&gt;
# Check strategy rules&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings/list?prefix=queue.strategy&lt;br /&gt;
&lt;br /&gt;
# Check queue for stuck messages&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/queue | jq &#039;.messages[]&#039;&lt;br /&gt;
&lt;br /&gt;
# Check logs for relay errors&lt;br /&gt;
docker logs stalwart-mail | grep -i &amp;quot;relay\|route\|fastmail\|mxroute&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Common causes:&#039;&#039;&#039;&lt;br /&gt;
* Route not configured (blank response from API)&lt;br /&gt;
* Strategy rule missing or malformed (0000 not checking local domain)&lt;br /&gt;
* TLS cert not valid (relay host rejects connection)&lt;br /&gt;
* Rate limit exceeded (queue backs up, check MXRoute limit)&lt;br /&gt;
* Port 465 blocked (ISP still blocking? test external relay directly)&lt;br /&gt;
&lt;br /&gt;
=== Database Corruption ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; &amp;quot;SQLite error&amp;quot; in logs, or can&#039;t connect to accounts&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/stalwart-app&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Backup corrupted database&lt;br /&gt;
cp data/store/data.sqlite data/store/data.sqlite.corrupted-$(date +%Y%m%d)&lt;br /&gt;
&lt;br /&gt;
# Delete corrupted DBs (queue/messages preserved)&lt;br /&gt;
rm data/store/*.sqlite&lt;br /&gt;
&lt;br /&gt;
# Start fresh (will recreate empty DBs)&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Reconfigure via API&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Outbound Queue Stuck ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Check queue:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/queue | jq &#039;.messages | length&#039;&lt;br /&gt;
&lt;br /&gt;
# If &amp;gt; 100 messages stuck, investigate:&lt;br /&gt;
docker logs stalwart-mail | tail -100 | grep -i &amp;quot;reject\|error\|fail&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Possible causes:&lt;br /&gt;
# - Rate limit hit (check MXRoute limit)&lt;br /&gt;
# - TLS cert issue with relay host&lt;br /&gt;
# - Firewall blocking outbound to relay&lt;br /&gt;
# - LDAP down (affects routing decision)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Spam Filter Too Aggressive ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Increase spam threshold:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; -X POST http://localhost:8181/api/settings \&lt;br /&gt;
  -H &amp;quot;Content-Type: application/json&amp;quot; \&lt;br /&gt;
  -d &#039;[{&amp;quot;type&amp;quot;:&amp;quot;insert&amp;quot;,&amp;quot;prefix&amp;quot;:&amp;quot;spam-filter.score&amp;quot;,&amp;quot;assert_empty&amp;quot;:false,&amp;quot;values&amp;quot;:[[&amp;quot;spam&amp;quot;,&amp;quot;6.0&amp;quot;]]}]&#039;&lt;br /&gt;
&lt;br /&gt;
# Verify&lt;br /&gt;
curl -s -u admin:&amp;lt;REDACTED&amp;gt; http://localhost:8181/api/settings/list?prefix=spam-filter&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Links &amp;amp; References ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Stalwart Docs:&#039;&#039;&#039; https://stalwartlabs.com/docs/&lt;br /&gt;
* &#039;&#039;&#039;Stalwart Admin API:&#039;&#039;&#039; http://localhost:8181/admin (Web UI)&lt;br /&gt;
* &#039;&#039;&#039;Stalwart Email Blog:&#039;&#039;&#039; https://stalwartlabs.com/blog/&lt;br /&gt;
* &#039;&#039;&#039;LDAP Documentation:&#039;&#039;&#039; https://ldapwiki.com/&lt;br /&gt;
* &#039;&#039;&#039;Sieve Mail Filters:&#039;&#039;&#039; https://tools.ietf.org/html/rfc5228&lt;br /&gt;
* &#039;&#039;&#039;Getmail Integration:&#039;&#039;&#039; &amp;lt;code&amp;gt;/home/mnw/Developer/SystemResiliency/services/STALWART.md&amp;lt;/code&amp;gt; (reference docs)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Related Services ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Authentik&#039;&#039;&#039; (auth): &amp;lt;code&amp;gt;/library/authentik-app/&amp;lt;/code&amp;gt; — LDAP provider, user directory&lt;br /&gt;
* &#039;&#039;&#039;SnappyMail&#039;&#039;&#039; (webmail): &amp;lt;code&amp;gt;/library/snappymail-app/&amp;lt;/code&amp;gt; — web mail frontend&lt;br /&gt;
* &#039;&#039;&#039;getmail&#039;&#039;&#039; (mail federation): &amp;lt;code&amp;gt;/library/email/getmail/&amp;lt;/code&amp;gt; — inbound mail syncing&lt;br /&gt;
* &#039;&#039;&#039;Let&#039;s Encrypt&#039;&#039;&#039; (TLS): &amp;lt;code&amp;gt;/etc/letsencrypt/&amp;lt;/code&amp;gt; — certificates for TLS&lt;br /&gt;
* &#039;&#039;&#039;VPS nginx&#039;&#039;&#039; (reverse proxy): Exposes mymail.wilsoz.com:993 externally&lt;br /&gt;
* &#039;&#039;&#039;Tailscale&#039;&#039;&#039; (VPN): Required for SDF relay via proxy&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Identity/AuthentikMatrix&amp;diff=26</id>
		<title>Identity/AuthentikMatrix</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Identity/AuthentikMatrix&amp;diff=26"/>
		<updated>2026-07-05T18:19:18Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from INFRASTRUCTURE_INVENTORY.md auth matrix&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from INFRASTRUCTURE_INVENTORY.md&#039;s Authentication Matrix section&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= How the Backup System Works =&lt;br /&gt;
&lt;br /&gt;
A beginner-friendly guide to understanding your new backup system.&lt;br /&gt;
&lt;br /&gt;
== The Big Picture ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
Your Server (stronghold)&lt;br /&gt;
         │&lt;br /&gt;
         │ 1. Dump databases to files&lt;br /&gt;
         ▼&lt;br /&gt;
   ┌─────────────┐&lt;br /&gt;
   │ PostgreSQL  │──► .sql.gz files&lt;br /&gt;
   │ Containers  │    (in /library/backup-stack/dumps/)&lt;br /&gt;
   └─────────────┘&lt;br /&gt;
         │&lt;br /&gt;
         │ 2. Restic reads all your data&lt;br /&gt;
         ▼&lt;br /&gt;
   ┌─────────────┐&lt;br /&gt;
   │   Restic    │──► Deduplicates, compresses, encrypts&lt;br /&gt;
   │  Container  │&lt;br /&gt;
   └─────────────┘&lt;br /&gt;
         │&lt;br /&gt;
         │ 3. Uploads to cloud storage&lt;br /&gt;
         ▼&lt;br /&gt;
   ┌─────────────┐&lt;br /&gt;
   │   Hetzner   │  Your encrypted backups live here&lt;br /&gt;
   │ Storage Box │  (in Germany, €3.81/month)&lt;br /&gt;
   └─────────────┘&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== What Gets Backed Up ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! What&lt;br /&gt;
! Where It Lives&lt;br /&gt;
! Size&lt;br /&gt;
! Why It Matters&lt;br /&gt;
|-&lt;br /&gt;
| Authentik DB&lt;br /&gt;
| PostgreSQL dump&lt;br /&gt;
| 14MB&lt;br /&gt;
| Your SSO - lose this, lose all logins&lt;br /&gt;
|-&lt;br /&gt;
| Vaultwarden&lt;br /&gt;
| SQLite file&lt;br /&gt;
| 356KB&lt;br /&gt;
| Your passwords!&lt;br /&gt;
|-&lt;br /&gt;
| Immich photos&lt;br /&gt;
| /raid1/.../Immich-Photos&lt;br /&gt;
| 149GB&lt;br /&gt;
| Family photos&lt;br /&gt;
|-&lt;br /&gt;
| Immich DB&lt;br /&gt;
| PostgreSQL dump&lt;br /&gt;
| 437MB&lt;br /&gt;
| Photo metadata, faces, albums&lt;br /&gt;
|-&lt;br /&gt;
| Jellyfin&lt;br /&gt;
| /var/lib/jellyfin&lt;br /&gt;
| 92GB&lt;br /&gt;
| Watch history, metadata&lt;br /&gt;
|-&lt;br /&gt;
| Nextcloud&lt;br /&gt;
| Config + PostgreSQL&lt;br /&gt;
| ~1GB&lt;br /&gt;
| Files, calendar, contacts&lt;br /&gt;
|-&lt;br /&gt;
| Open-WebUI&lt;br /&gt;
| SQLite + uploads&lt;br /&gt;
| 1.7GB&lt;br /&gt;
| AI chat history&lt;br /&gt;
|-&lt;br /&gt;
| Memos, Linkwarden, Mailcow&lt;br /&gt;
| Various&lt;br /&gt;
| Small&lt;br /&gt;
| Notes, bookmarks, email&lt;br /&gt;
|-&lt;br /&gt;
| MediaWiki&lt;br /&gt;
| Config bind mount + MariaDB dump + images volume&lt;br /&gt;
| ~300KB&lt;br /&gt;
| The wilsoz wiki — infra docs, runbooks, ADRs&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== The Components ==&lt;br /&gt;
&lt;br /&gt;
=== 1. Restic Container (&amp;lt;code&amp;gt;restic-backup&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
A small container that knows how to:&lt;br /&gt;
* Read your data from mounted directories&lt;br /&gt;
* Encrypt everything with your password&lt;br /&gt;
* Upload to Hetzner via SFTP&lt;br /&gt;
* Deduplicate (only upload changed data)&lt;br /&gt;
&lt;br /&gt;
=== 2. Database Dump Script (&amp;lt;code&amp;gt;host-dump-databases.sh&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
Runs on the host (not in container) because it needs Docker access.&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
authentik-postgresql → authentik-20260201.sql.gz&lt;br /&gt;
nextcloud-db         → nextcloud-20260201.sql.gz&lt;br /&gt;
immich_postgres      → immich-20260201.sql.gz&lt;br /&gt;
linkwarden_postgres  → linkwarden-20260201.sql.gz&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. Backup Script (&amp;lt;code&amp;gt;backup-to-hetzner.sh&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
Runs inside the container:&lt;br /&gt;
# Connects to Hetzner via SSH (port 23, key auth)&lt;br /&gt;
# Reads all your data directories&lt;br /&gt;
# Uploads new/changed files&lt;br /&gt;
# Prunes old snapshots (keeps 7 daily, 4 weekly, 6 monthly)&lt;br /&gt;
&lt;br /&gt;
=== 4. Systemd Timers ===&lt;br /&gt;
Schedules everything to run automatically:&lt;br /&gt;
* &#039;&#039;&#039;3:00 AM&#039;&#039;&#039; - Full backup&lt;br /&gt;
* &#039;&#039;&#039;6:00 AM&#039;&#039;&#039; - Integrity check&lt;br /&gt;
* &#039;&#039;&#039;Sunday 7:00 AM&#039;&#039;&#039; - Restore test&lt;br /&gt;
&lt;br /&gt;
== How Restic Works (The Magic) ==&lt;br /&gt;
&lt;br /&gt;
=== Deduplication ===&lt;br /&gt;
Restic breaks files into chunks. If you have 1000 photos and add 1 new one:&lt;br /&gt;
* First backup: uploads all 1000 photos (~149GB)&lt;br /&gt;
* Second backup: uploads only the 1 new photo (~5MB)&lt;br /&gt;
&lt;br /&gt;
=== Encryption ===&lt;br /&gt;
Everything is encrypted with your password before leaving your server.&lt;br /&gt;
* Hetzner can&#039;t read your data&lt;br /&gt;
* Without the password, backups are useless&lt;br /&gt;
* &#039;&#039;&#039;Password:&#039;&#039;&#039; &amp;lt;code&amp;gt;E+mVDSjj8VsB5Feklelsq/BAJ3FmMX3E&amp;lt;/code&amp;gt; (stored in .env)&lt;br /&gt;
&lt;br /&gt;
=== Snapshots ===&lt;br /&gt;
Each backup creates a &amp;quot;snapshot&amp;quot; - a point-in-time view of your data.&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
$ restic snapshots&lt;br /&gt;
ID        Time                 Host        Tags&lt;br /&gt;
─────────────────────────────────────────────────&lt;br /&gt;
a1b2c3d4  2026-02-01 23:50    stronghold  full&lt;br /&gt;
e5f6g7h8  2026-02-02 03:00    stronghold  full&lt;br /&gt;
...&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You can restore any snapshot, not just the latest.&lt;br /&gt;
&lt;br /&gt;
== How to Check on Backups ==&lt;br /&gt;
&lt;br /&gt;
=== Is the backup running? ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs restic-backup&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== How big is my backup repo? ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic stats&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== List all snapshots ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic snapshots&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Check repo integrity ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic check&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== How to Restore ==&lt;br /&gt;
&lt;br /&gt;
=== Restore everything (disaster recovery) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic restore latest --target /tmp/full-restore&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Restore one file ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/restore \&lt;br /&gt;
  --include &amp;quot;/data/vaultwarden/data/db.sqlite3&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Restore from specific date ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# List snapshots to find the ID&lt;br /&gt;
docker exec restic-backup restic snapshots&lt;br /&gt;
&lt;br /&gt;
# Restore that snapshot&lt;br /&gt;
docker exec restic-backup restic restore a1b2c3d4 --target /tmp/restore&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Important Files ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
/library/backup-stack/&lt;br /&gt;
├── docker-compose.yml    # Container definition&lt;br /&gt;
├── .env                  # Passwords and settings (SECRET!)&lt;br /&gt;
├── ssh-config            # SSH settings for Hetzner&lt;br /&gt;
├── dumps/                # Database dumps live here&lt;br /&gt;
│   ├── authentik-20260201.sql.gz&lt;br /&gt;
│   ├── immich-20260201.sql.gz&lt;br /&gt;
│   └── ...&lt;br /&gt;
├── scripts/&lt;br /&gt;
│   ├── host-dump-databases.sh  # Dumps DBs (runs on host)&lt;br /&gt;
│   ├── backup-to-hetzner.sh    # Main backup (runs in container)&lt;br /&gt;
│   └── validate-backups.sh     # Tests backups&lt;br /&gt;
└── systemd/              # Timer definitions&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== What Happens If... ==&lt;br /&gt;
&lt;br /&gt;
=== Power goes out during backup? ===&lt;br /&gt;
Restic is crash-safe. Next backup continues from where it left off.&lt;br /&gt;
&lt;br /&gt;
=== Hetzner goes down? ===&lt;br /&gt;
Your local data is fine. Backups resume when Hetzner is back.&lt;br /&gt;
&lt;br /&gt;
=== You delete a file by accident? ===&lt;br /&gt;
Restore it from a previous snapshot.&lt;br /&gt;
&lt;br /&gt;
=== Your server dies completely? ===&lt;br /&gt;
# Get new server&lt;br /&gt;
# Install Docker&lt;br /&gt;
# Connect to Hetzner&lt;br /&gt;
# Run &amp;lt;code&amp;gt;restic restore latest&amp;lt;/code&amp;gt;&lt;br /&gt;
# Rebuild containers with restored data&lt;br /&gt;
&lt;br /&gt;
=== You forget the password? ===&lt;br /&gt;
&#039;&#039;&#039;Game over.&#039;&#039;&#039; The backups are encrypted. No password = no data.&lt;br /&gt;
Save it somewhere safe (Vaultwarden, password manager, printed paper in safe).&lt;br /&gt;
&lt;br /&gt;
== Costs ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Cost&lt;br /&gt;
|-&lt;br /&gt;
| Hetzner Storage Box 1TB&lt;br /&gt;
| €3.81/month&lt;br /&gt;
|-&lt;br /&gt;
| Everything else&lt;br /&gt;
| Free (self-hosted)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Every night at 3 AM:&#039;&#039;&#039;&lt;br /&gt;
** Databases get dumped to .sql.gz files&lt;br /&gt;
** Restic encrypts and uploads everything to Germany&lt;br /&gt;
** Old backups get cleaned up automatically&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Every morning at 6 AM:&#039;&#039;&#039;&lt;br /&gt;
** System checks backup integrity&lt;br /&gt;
** Alerts you via ntfy if something&#039;s wrong&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Every Sunday:&#039;&#039;&#039;&lt;br /&gt;
** Does a test restore to verify backups actually work&lt;br /&gt;
&lt;br /&gt;
Your data is now protected. Sleep well.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
= Disaster Recovery Runbook =&lt;br /&gt;
&lt;br /&gt;
= Disaster Recovery Runbook =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Last Updated:&#039;&#039;&#039; 2026-06-21&lt;br /&gt;
&#039;&#039;&#039;Print this and store in a safe place.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
:&#039;&#039;&#039;Keep DR current:&#039;&#039;&#039; after adding/changing/removing any service, run&lt;br /&gt;
:&amp;lt;code&amp;gt;scripts/dr-drift-check.sh&amp;lt;/code&amp;gt; and act on whatever it flags. See&lt;br /&gt;
:&amp;lt;code&amp;gt;DR_MAINTENANCE.md&amp;lt;/code&amp;gt; for the full workflow. Don&#039;t skip this — gradual drift&lt;br /&gt;
:is how DR plans silently rot until the day you actually need them.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
&lt;br /&gt;
=== Stronghold (primary) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Hetzner Storage Box&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;u540853.your-storagebox.de&amp;lt;/code&amp;gt; port &#039;&#039;&#039;23&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Repo path&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;sftp:u540853@u540853.your-storagebox.de:./backups&amp;lt;/code&amp;gt; (port 23 comes from SSH config, &#039;&#039;&#039;not&#039;&#039;&#039; the URL — see note)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;SSH Key&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.ssh/id_rsa&amp;lt;/code&amp;gt; (on stronghold)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Restic Password&#039;&#039;&#039;&lt;br /&gt;
| In &amp;lt;code&amp;gt;/library/backup-stack/.env&amp;lt;/code&amp;gt; (&amp;lt;code&amp;gt;HETZNER_PASSWORD&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Backup Schedule&#039;&#039;&#039;&lt;br /&gt;
| Daily 03:00 CST&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Retention&#039;&#039;&#039;&lt;br /&gt;
| 7 daily, 4 weekly, 6 monthly&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== VPS (wilsoz.com) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Hetzner Storage Box&#039;&#039;&#039;&lt;br /&gt;
| same box, separate repo&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Repo path&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;sftp:u540853@u540853.your-storagebox.de:./backups-vps&amp;lt;/code&amp;gt; (port 23 comes from SSH config, &#039;&#039;&#039;not&#039;&#039;&#039; the URL — see note)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;SSH Key&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.ssh/id_ed25519&amp;lt;/code&amp;gt; (on VPS)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Restic Password&#039;&#039;&#039;&lt;br /&gt;
| same &amp;lt;code&amp;gt;HETZNER_PASSWORD&amp;lt;/code&amp;gt; — stored in &amp;lt;code&amp;gt;/etc/restic-vps.env&amp;lt;/code&amp;gt; on VPS&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Backup Schedule&#039;&#039;&#039;&lt;br /&gt;
| Daily 04:30 UTC (offset from stronghold)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Retention&#039;&#039;&#039;&lt;br /&gt;
| 7 daily, 4 weekly, 6 monthly&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Covers&#039;&#039;&#039;&lt;br /&gt;
| Forgejo data + repos, Atuin (pg_dump), ntfy, nginx, Let&#039;s Encrypt certs, &amp;lt;code&amp;gt;/opt/&amp;lt;/code&amp;gt; custom scripts&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
:&#039;&#039;&#039;⚠️ SFTP repo URL gotcha (port vs. path):&#039;&#039;&#039; In restic&#039;s single-colon SFTP form&lt;br /&gt;
:(&amp;lt;code&amp;gt;sftp:user@host:path&amp;lt;/code&amp;gt;) &#039;&#039;&#039;everything after the host colon is the repository path&#039;&#039;&#039; —&lt;br /&gt;
:there is no port in the URL. The storage box&#039;s port &#039;&#039;&#039;23&#039;&#039;&#039; must be set in SSH config&lt;br /&gt;
:(&amp;lt;code&amp;gt;~/.ssh/config&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;Port 23&amp;lt;/code&amp;gt; for host &amp;lt;code&amp;gt;u540853.your-storagebox.de&amp;lt;/code&amp;gt;), which both&lt;br /&gt;
:stronghold and the &amp;lt;code&amp;gt;restic-backup&amp;lt;/code&amp;gt; container already have. Writing the port into the&lt;br /&gt;
:URL as &amp;lt;code&amp;gt;:23/backups&amp;lt;/code&amp;gt; makes restic look for a repo at the literal path &amp;lt;code&amp;gt;23/backups&amp;lt;/code&amp;gt; and&lt;br /&gt;
:fail with &#039;&#039;&amp;quot;repository does not exist&amp;quot;&#039;&#039;. Always use the relative &amp;lt;code&amp;gt;:./backups&amp;lt;/code&amp;gt; /&lt;br /&gt;
:&amp;lt;code&amp;gt;:./backups-vps&amp;lt;/code&amp;gt; form. (This exact mistake caused false &amp;quot;VPS: no snapshots found&amp;quot;&lt;br /&gt;
:alerts from the n8n &#039;&#039;Backup Status Alert (SSH)&#039;&#039; workflow until 2026-06-29.)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== What&#039;s Backed Up ==&lt;br /&gt;
&lt;br /&gt;
=== Service Data (files) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Path in Backup&lt;br /&gt;
! Source on Host&lt;br /&gt;
! Contents&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/authentik&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/authentik-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| SSO/identity provider config&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/vaultwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/vaultwarden-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Password vault (SQLite DB + attachments)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/nextcloud&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/nextcloud-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| File sync config + data&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/immich-config&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/immich-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Photo manager config&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/immich-photos&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/raid1/Consolidated/Picture/Immich-Photos/&amp;lt;/code&amp;gt;&lt;br /&gt;
| All photos (149G)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/jellyfin&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Media server metadata (92G)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/jellyfin-etc&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/etc/jellyfin/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Jellyfin system config&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/memos&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/memo-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Notes app&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/linkwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/linkwarden-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Bookmark manager&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/open-webui&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/open-webui-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| AI chat interface&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/matrix-chat&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/matrix-chat/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Synapse + bridge configs + signing key&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/stalwart&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/stalwart-app/data/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Mail server data&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/snappymail&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/snappymail-app/data/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Webmail data&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/mailcow&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/opt/mailcow-dockerized/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Legacy mail (to be archived)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/home&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/home/mnw/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Home dir: dotfiles, scripts, Documents, source, SSH/GPG keys (selective excludes)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/developer&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/home/mnw/Developer/&amp;lt;/code&amp;gt;&lt;br /&gt;
| All dev projects (7.9G) — also covered by &amp;lt;code&amp;gt;/data/home&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/beets&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/raid1/my-tunes/.beets/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Music library DB&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/openbao&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao secret store — &#039;&#039;&#039;incl. &amp;lt;code&amp;gt;.init-output.json&amp;lt;/code&amp;gt; (root token + unseal key) and &amp;lt;code&amp;gt;.scripts-token&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;.claude-token&amp;lt;/code&amp;gt;.&#039;&#039;&#039; Bootstrap-critical: must be restored and unsealed &#039;&#039;before&#039;&#039; services that depend on secrets.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/ezbookkeeping&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/ezbookkeeping-app/data/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Personal finance tracker (SQLite + uploads)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/omada&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/omada-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Omada SDN controller — switch/AP config &amp;amp; topology&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Database Dumps (in &amp;lt;code&amp;gt;/dumps/&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! File Pattern&lt;br /&gt;
! Source Container&lt;br /&gt;
! Database&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-app_postgresql_1&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik identity DB&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nextcloud-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;nextcloud-db&amp;lt;/code&amp;gt;&lt;br /&gt;
| Nextcloud DB&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;immich-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;immich_postgres&amp;lt;/code&amp;gt;&lt;br /&gt;
| Immich photo DB (~460MB compressed)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;linkwarden-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;linkwarden_postgres&amp;lt;/code&amp;gt;&lt;br /&gt;
| Linkwarden bookmark DB&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-postgres&amp;lt;/code&amp;gt;&lt;br /&gt;
| Synapse + bridge DBs (~1MB compressed)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== NOT Backed Up (re-acquirable) ===&lt;br /&gt;
&lt;br /&gt;
* Jellyfin media files (movies, TV)&lt;br /&gt;
* Ollama models (~20G, re-downloadable)&lt;br /&gt;
* Steam games&lt;br /&gt;
* VirtualBox VMs&lt;br /&gt;
* node_modules, .venv, .git/objects&lt;br /&gt;
* Wyoming voice models (&amp;lt;code&amp;gt;piper-data-app&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;whisper-data-app&amp;lt;/code&amp;gt;) — redownloadable&lt;br /&gt;
* Prometheus metric history, Grafana dashboards data, uptime-kuma — re-buildable from compose&lt;br /&gt;
&lt;br /&gt;
=== ⚠️ Known Gaps — Data Not Currently Backed Up ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Severity&lt;br /&gt;
! What&lt;br /&gt;
! Where&lt;br /&gt;
! Notes&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HIGH&#039;&#039;&#039;&lt;br /&gt;
| AudioBookshelf config + metadata&lt;br /&gt;
| &amp;lt;code&amp;gt;/usr/share/audiobookshelf/config/&amp;lt;/code&amp;gt; (38M), &amp;lt;code&amp;gt;/usr/share/audiobookshelf/metadata/&amp;lt;/code&amp;gt; (936M)&lt;br /&gt;
| systemd service, not Docker — not yet bind-mounted into restic container. Lose library DB, progress, and cover art on rebuild.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Previously flagged gaps (all closed as of 2026-06-21): Nextcloud user files, Matrix media, Mealie, n8n named volumes, uptime-kuma, Grafana, AdGuard — all now covered by bind mounts in the restic container compose.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Recovery Scenarios ==&lt;br /&gt;
&lt;br /&gt;
=== Scenario 1: Single Service Failure ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example:&#039;&#039;&#039; Immich database corrupted, need to restore just Immich.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Stop the service&lt;br /&gt;
cd /library/immich-app &amp;amp;&amp;amp; docker compose down&lt;br /&gt;
&lt;br /&gt;
# 2. List available snapshots&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; restic snapshots&#039;&lt;br /&gt;
&lt;br /&gt;
# 3. Restore service data from latest snapshot&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; \&lt;br /&gt;
   restic restore latest --target /tmp/restore --include /data/immich-config&#039;&lt;br /&gt;
&lt;br /&gt;
# 4. Copy restored data to host&lt;br /&gt;
docker cp restic-backup:/tmp/restore/data/immich-config /tmp/immich-restore&lt;br /&gt;
&lt;br /&gt;
# 5. Replace the broken data&lt;br /&gt;
mv /library/immich-app /library/immich-app.broken&lt;br /&gt;
mv /tmp/immich-restore /library/immich-app&lt;br /&gt;
&lt;br /&gt;
# 6. Restore the database dump&lt;br /&gt;
docker cp restic-backup:/tmp/restore/dumps/immich-YYYYMMDD.sql.gz /tmp/&lt;br /&gt;
gunzip /tmp/immich-YYYYMMDD.sql.gz&lt;br /&gt;
# Start just the database container&lt;br /&gt;
docker compose up -d immich_postgres&lt;br /&gt;
# Wait for it to be ready, then restore&lt;br /&gt;
docker exec -i immich_postgres psql -U postgres immich &amp;lt; /tmp/immich-YYYYMMDD.sql&lt;br /&gt;
&lt;br /&gt;
# 7. Start the full service&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 8. Clean up&lt;br /&gt;
docker exec restic-backup rm -rf /tmp/restore&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario 2: Full Server Rebuild ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Starting from:&#039;&#039;&#039; Fresh Linux install on new hardware.&lt;br /&gt;
&lt;br /&gt;
==== Bootstrap Order (DO NOT SHUFFLE) ====&lt;br /&gt;
&lt;br /&gt;
Many services depend on OpenBao (secret store) and Authentik (SSO). Bring them&lt;br /&gt;
up in this order so each layer has its dependencies in place:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1. Host: Docker, restic, Tailscale, SSH key&lt;br /&gt;
2. Restore everything from Hetzner via restic (raw files)&lt;br /&gt;
3. OpenBao  ← unseal it first; it holds tokens other services need&lt;br /&gt;
4. Authentik (DB dump → start) ← SSO for everything&lt;br /&gt;
5. Vaultwarden ← human-facing creds; needed for me-the-operator&lt;br /&gt;
6. Restore Claude Code access (bw CLI, MCP wrappers) ← so future-you can drive recovery&lt;br /&gt;
7. Postgres-backed services (Immich, Nextcloud, Matrix, Linkwarden): DB dump → start&lt;br /&gt;
8. Stalwart + SnappyMail (mail)&lt;br /&gt;
9. Everything else (n8n, Grafana, Mealie, etc.)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 1: Install Prerequisites ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Install Docker&lt;br /&gt;
curl -fsSL https://get.docker.com | sh&lt;br /&gt;
sudo usermod -aG docker mnw&lt;br /&gt;
&lt;br /&gt;
# Install restic&lt;br /&gt;
sudo apt install restic&lt;br /&gt;
&lt;br /&gt;
# Set Docker MTU for Tailscale compatibility&lt;br /&gt;
# Add to /etc/docker/daemon.json:&lt;br /&gt;
# {&amp;quot;mtu&amp;quot;: 1280}&lt;br /&gt;
sudo systemctl restart docker&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 2: Restore SSH Keys ====&lt;br /&gt;
&lt;br /&gt;
You need the SSH key to access Hetzner. If you have a copy:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p ~/.ssh&lt;br /&gt;
# Copy id_rsa from your backup/safe location&lt;br /&gt;
chmod 600 ~/.ssh/id_rsa&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you don&#039;t have the key, generate a new one and add it to Hetzner:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ssh-keygen -t rsa -b 4096&lt;br /&gt;
# Add public key via Hetzner Robot panel → Storage Box → SSH Keys&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 3: Bootstrap Restic ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create the backup stack directory&lt;br /&gt;
sudo mkdir -p /library/backup-stack&lt;br /&gt;
cd /library/backup-stack&lt;br /&gt;
&lt;br /&gt;
# Create .env with Hetzner credentials:&lt;br /&gt;
cat &amp;gt; .env &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
HETZNER_REPO=sftp:u540853@u540853.your-storagebox.de:23/backups&lt;br /&gt;
HETZNER_PASSWORD=&amp;lt;your-restic-password&amp;gt;&lt;br /&gt;
NTFY_TOKEN=tk_0fhigfiglbjw9r8sk9jv4hpr7q67f&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Test connection&lt;br /&gt;
export $(grep -v &#039;^#&#039; .env | xargs)&lt;br /&gt;
restic -r &amp;quot;$HETZNER_REPO&amp;quot; snapshots&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 4: Restore Everything ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create target directories&lt;br /&gt;
sudo mkdir -p /library /raid1/Consolidated/Picture/Immich-Photos&lt;br /&gt;
&lt;br /&gt;
# Restore all service data&lt;br /&gt;
restic -r &amp;quot;$HETZNER_REPO&amp;quot; restore latest --target /&lt;br /&gt;
&lt;br /&gt;
# This puts files at their original paths:&lt;br /&gt;
#   /data/authentik → needs moving to /library/authentik-app/&lt;br /&gt;
#   /data/developer → needs moving to /home/mnw/Developer/&lt;br /&gt;
#   etc.&lt;br /&gt;
&lt;br /&gt;
# Move restored data to correct locations&lt;br /&gt;
sudo mv /data/authentik /library/authentik-app&lt;br /&gt;
sudo mv /data/vaultwarden /library/vaultwarden-app&lt;br /&gt;
sudo mv /data/nextcloud /library/nextcloud-app&lt;br /&gt;
sudo mv /data/immich-config /library/immich-app&lt;br /&gt;
sudo mv /data/immich-photos/* /raid1/Consolidated/Picture/Immich-Photos/&lt;br /&gt;
sudo mv /data/jellyfin /var/lib/jellyfin&lt;br /&gt;
sudo mv /data/jellyfin-etc /etc/jellyfin&lt;br /&gt;
sudo mv /data/memos /library/memo-app&lt;br /&gt;
sudo mv /data/linkwarden /library/linkwarden-app&lt;br /&gt;
sudo mv /data/open-webui /library/open-webui-app&lt;br /&gt;
sudo mv /data/matrix-chat /library/matrix-chat&lt;br /&gt;
sudo mv /data/stalwart /library/stalwart-app/data&lt;br /&gt;
sudo mv /data/snappymail /library/snappymail-app/data&lt;br /&gt;
sudo mv /data/developer /home/mnw/Developer&lt;br /&gt;
sudo mv /data/beets /raid1/my-tunes/.beets&lt;br /&gt;
sudo chown -R mnw:mnw /home/mnw/Developer&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 4b: Bootstrap OpenBao (Secret Store) — REQUIRED before DBs ====&lt;br /&gt;
&lt;br /&gt;
OpenBao holds tokens and secrets that downstream services + scripts read at startup.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Move restored data into place&lt;br /&gt;
sudo mv /data/openbao /library/openbao-app&lt;br /&gt;
sudo chown -R 1000:1000 /library/openbao-app  # OpenBao runs as uid 1000&lt;br /&gt;
&lt;br /&gt;
# 2. Start the container&lt;br /&gt;
cd /library/openbao-app &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 3. Unseal — uses the unseal key from .init-output.json (restored from backup)&lt;br /&gt;
sudo /usr/local/bin/openbao-unseal.sh&lt;br /&gt;
# If that script isn&#039;t restored yet, manually:&lt;br /&gt;
#   ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)&lt;br /&gt;
#   UNSEAL_KEY=$(jq -r .unseal_keys_b64[0] /library/openbao-app/.init-output.json)&lt;br /&gt;
#   docker exec openbao bao operator unseal &amp;quot;$UNSEAL_KEY&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# 4. Install the auto-unseal systemd unit so it survives reboots&lt;br /&gt;
sudo install -m 0755 scripts/openbao/openbao-unseal.sh /usr/local/bin/&lt;br /&gt;
sudo install -m 0644 scripts/openbao/openbao-unseal.service /etc/systemd/system/&lt;br /&gt;
sudo systemctl daemon-reload &amp;amp;&amp;amp; sudo systemctl enable --now openbao-unseal.service&lt;br /&gt;
&lt;br /&gt;
# 5. Verify by reading a known secret&lt;br /&gt;
TOKEN=$(cat /library/openbao-app/.scripts-token)&lt;br /&gt;
curl -sf http://127.0.0.1:8200/v1/secret/data/ntfy -H &amp;quot;X-Vault-Token: $TOKEN&amp;quot; | jq .data.data&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;If &amp;lt;code&amp;gt;.init-output.json&amp;lt;/code&amp;gt; is lost:&#039;&#039;&#039; OpenBao is unrecoverable without it. Print&lt;br /&gt;
it now (or keep it in a sealed envelope) — it contains the root token AND the&lt;br /&gt;
unseal key. Without these, you cannot decrypt anything in the Raft store.&lt;br /&gt;
&lt;br /&gt;
==== Step 5: Restore Databases ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# For each service, start just the DB container, restore the dump, then start the rest.&lt;br /&gt;
# Example for Authentik:&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
docker compose up -d postgresql&lt;br /&gt;
sleep 10&lt;br /&gt;
gunzip -c /dumps/authentik-YYYYMMDD.sql.gz | docker exec -i authentik-app_postgresql_1 psql -U authentik authentik&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Repeat for: nextcloud, immich, linkwarden, matrix&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 6: Restore System Services ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Jellyfin (systemd, not Docker)&lt;br /&gt;
sudo apt install jellyfin&lt;br /&gt;
# Data already restored to /var/lib/jellyfin and /etc/jellyfin&lt;br /&gt;
&lt;br /&gt;
# AudioBookshelf&lt;br /&gt;
# Install from package, data at /usr/share/audiobookshelf&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 6b: Restore Claude Code Access (Vaultwarden MCP) ====&lt;br /&gt;
&lt;br /&gt;
Once Vaultwarden is up (Step 5 + nginx routed), restore the bw CLI and MCP&lt;br /&gt;
wrappers so a Claude session can help you finish recovery from this point on.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Install bw CLI (snap is the official Bitwarden source)&lt;br /&gt;
sudo snap install bw&lt;br /&gt;
&lt;br /&gt;
# 2. Install Bitwarden MCP server (npm, globally)&lt;br /&gt;
npm install -g @bitwarden/mcp-server&lt;br /&gt;
&lt;br /&gt;
# 3. Restore wrappers — these are in /data/home/.config/claude/ from backup&lt;br /&gt;
mkdir -p ~/.config/claude&lt;br /&gt;
sudo cp /data/home/.config/claude/{vaultwarden.env,bw-mcp-start.sh,bw-get.py} ~/.config/claude/&lt;br /&gt;
chmod 600 ~/.config/claude/vaultwarden.env&lt;br /&gt;
chmod 700 ~/.config/claude/bw-mcp-start.sh ~/.config/claude/bw-get.py&lt;br /&gt;
&lt;br /&gt;
# 4. Verify bw can talk to Vaultwarden (not the public Bitwarden cloud!)&lt;br /&gt;
bw config server  # MUST print https://vault.wilsoz.com — if not, run: bw config server https://vault.wilsoz.com&lt;br /&gt;
bw login --apikey  # uses BW_CLIENTID + BW_CLIENTSECRET from env&lt;br /&gt;
&lt;br /&gt;
# 5. Wire MCPs into ~/.claude.json (vault + bitwarden entries)&lt;br /&gt;
# See services/VAULTWARDEN.md for the exact JSON structure if it needs to be rebuilt.&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
See &amp;lt;code&amp;gt;services/VAULTWARDEN.md&amp;lt;/code&amp;gt; for full setup including the &amp;lt;code&amp;gt;mcp-server-bitwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
symlink bug workaround.&lt;br /&gt;
&lt;br /&gt;
==== Step 7: Re-establish Networking ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Install Tailscale&lt;br /&gt;
curl -fsSL https://tailscale.com/install.sh | sh&lt;br /&gt;
sudo tailscale up&lt;br /&gt;
&lt;br /&gt;
# VPS nginx configs point to Tailscale IPs — update if IP changes&lt;br /&gt;
# VPS SSH: ssh -p 1981 mnw@wilsoz.com&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario 3: Restore Specific Files ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Restore a single file from a specific snapshot&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; \&lt;br /&gt;
   restic restore SNAPSHOT_ID --target /tmp/restore --include /data/developer/SystemResiliency/STATUS.md&#039;&lt;br /&gt;
&lt;br /&gt;
docker cp restic-backup:/tmp/restore/data/developer/SystemResiliency/STATUS.md /tmp/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario 4: Matrix Chat Recovery ===&lt;br /&gt;
&lt;br /&gt;
Matrix requires special care — the signing key is critical.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Restore Matrix data&lt;br /&gt;
restic restore latest --target /tmp/restore --include /data/matrix-chat&lt;br /&gt;
&lt;br /&gt;
# 2. Critical files:&lt;br /&gt;
#    - synapse/signing.key — IRREPLACEABLE, needed for federation&lt;br /&gt;
#    - synapse/homeserver.yaml — server config&lt;br /&gt;
#    - bridges/*/config.yaml — bridge configs&lt;br /&gt;
#    - bridges/*/registration.yaml — bridge registrations&lt;br /&gt;
&lt;br /&gt;
# 3. Restore database&lt;br /&gt;
gunzip -c /dumps/matrix-YYYYMMDD.sql.gz | docker exec -i matrix-postgres psql -U synapse&lt;br /&gt;
&lt;br /&gt;
# 4. Restart stack&lt;br /&gt;
cd /library/matrix-chat &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Verification Commands ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# List all snapshots&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; restic snapshots&#039;&lt;br /&gt;
&lt;br /&gt;
# Check repository integrity&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; restic check&#039;&lt;br /&gt;
&lt;br /&gt;
# Check repo size on Hetzner&lt;br /&gt;
ssh -p 23 u540853@u540853.your-storagebox.de &amp;quot;du -sh backups/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# List files in a snapshot&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; \&lt;br /&gt;
   restic ls latest /data/matrix-chat&#039;&lt;br /&gt;
&lt;br /&gt;
# Verify a DB dump&lt;br /&gt;
gunzip -t /library/backup-stack/dumps/immich-YYYYMMDD.sql.gz &amp;amp;&amp;amp; echo &amp;quot;OK&amp;quot; || echo &amp;quot;CORRUPT&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Manual backup trigger&lt;br /&gt;
sudo /library/backup-stack/scripts/host-dump-databases.sh&lt;br /&gt;
docker exec restic-backup /scripts/backup-to-hetzner.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Automated Monitoring ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Check&lt;br /&gt;
! Frequency&lt;br /&gt;
! Alert Channel&lt;br /&gt;
|-&lt;br /&gt;
| Backup age (n8n)&lt;br /&gt;
| Every 6h&lt;br /&gt;
| ntfy (nc700x-alerts) + Matrix&lt;br /&gt;
|-&lt;br /&gt;
| Integrity check (restic)&lt;br /&gt;
| Daily 06:00&lt;br /&gt;
| ntfy if FAIL&lt;br /&gt;
|-&lt;br /&gt;
| Restore test (restic)&lt;br /&gt;
| Weekly Sunday 07:00&lt;br /&gt;
| ntfy if FAIL&lt;br /&gt;
|-&lt;br /&gt;
| Disk space (n8n)&lt;br /&gt;
| Every 4h&lt;br /&gt;
| ntfy (nc700x-alerts) + Matrix&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Key Credentials ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Keep a copy of these offline:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Credential&lt;br /&gt;
! Location&lt;br /&gt;
|-&lt;br /&gt;
| Hetzner SSH key&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.ssh/id_rsa&amp;lt;/code&amp;gt; on stronghold&lt;br /&gt;
|-&lt;br /&gt;
| Restic password&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/backup-stack/.env&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Hetzner account&lt;br /&gt;
| Robot panel, u540853&lt;br /&gt;
|-&lt;br /&gt;
| ntfy token&lt;br /&gt;
| &amp;lt;code&amp;gt;tk_0fhigfiglbjw9r8sk9jv4hpr7q67f&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Authentik admin&lt;br /&gt;
| &amp;lt;code&amp;gt;https://key.wilsoz.com/if/admin/&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| VPS SSH&lt;br /&gt;
| &amp;lt;code&amp;gt;ssh -p 1981 mnw@wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Synapse shared secret&lt;br /&gt;
| In &amp;lt;code&amp;gt;homeserver.yaml&amp;lt;/code&amp;gt; line 50&lt;br /&gt;
|-&lt;br /&gt;
| Matrix signing key&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/matrix-chat/synapse/signing.key&amp;lt;/code&amp;gt; — &#039;&#039;&#039;IRREPLACEABLE&#039;&#039;&#039;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Restore Test Log ==&lt;br /&gt;
&lt;br /&gt;
=== 2026-02-23 — False Failure Alerts + Upload Anomaly Detection ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem 1 — False failure alert after meta-array removal:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;code&amp;gt;validate-backups.sh&amp;lt;/code&amp;gt; still contained a &amp;lt;code&amp;gt;restic check&amp;lt;/code&amp;gt; block against &amp;lt;code&amp;gt;$METAARRAY_REPO&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;$METAARRAY_PASSWORD&amp;lt;/code&amp;gt;. Both vars were removed from &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; when meta-array was dropped on 2026-02-22. Empty repo var → restic failed → &amp;lt;code&amp;gt;ERRORS++&amp;lt;/code&amp;gt; → ntfy &amp;lt;code&amp;gt;nc700x-alerts&amp;lt;/code&amp;gt; fired every day.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039; Removed the dead meta-array block from &amp;lt;code&amp;gt;validate-backups.sh&amp;lt;/code&amp;gt; (lines 44–52).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem 2 — Large upload on first run after container recreation:&#039;&#039;&#039;&lt;br /&gt;
Restic container was recreated during meta-array removal, clearing the local cache. Without cache, restic reported &amp;quot;no parent snapshot found&amp;quot; and re-scanned all 441,884 files (all showed as &amp;quot;new&amp;quot;). Chunk dedup still worked correctly — only 11.5 GiB of 314 GiB was actually uploaded. Confirmed by upload anomaly report: Stalwart ~8k new email files (ongoing Fastmail getmail import) + &amp;lt;code&amp;gt;/data/home&amp;lt;/code&amp;gt; (all new to the repo without a parent reference). Normal next run.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Enhancement — Upload anomaly detection:&#039;&#039;&#039;&lt;br /&gt;
Added &amp;lt;code&amp;gt;detect_upload_anomaly()&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt;:&lt;br /&gt;
* Tracks upload size per run in &amp;lt;code&amp;gt;/dumps/upload-history.txt&amp;lt;/code&amp;gt; (rolling 30 entries)&lt;br /&gt;
* If upload &amp;gt;2.5× rolling average AND &amp;gt;500 MiB, runs &amp;lt;code&amp;gt;restic diff &amp;lt;prev&amp;gt; &amp;lt;cur&amp;gt;&amp;lt;/code&amp;gt; and summarises changes by service path&lt;br /&gt;
* POSTs JSON payload to n8n webhook (&amp;lt;code&amp;gt;whO5LBZHQHCXXG9I&amp;lt;/code&amp;gt;) which formats and sends to Matrix&lt;br /&gt;
* ntfy not used — this is informational, not an emergency&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
=== 2026-02-22 — Recurring SSH Timeout Incident ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem:&#039;&#039;&#039; Backups failing with &amp;lt;code&amp;gt;subprocess ssh: Timeout&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;Fatal: unable to save snapshot: context canceled&amp;lt;/code&amp;gt;. Hetzner SFTP server drops idle connections (~5 min) during restic&#039;s local scan phase (22+ min). Compounded by 96 accumulated index files (documented threshold: 49) because prune was also failing.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Secondary cause removed:&#039;&#039;&#039; meta-array (ma.sdf.org) secondary backup target was unreliable. Since &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt; used &amp;lt;code&amp;gt;set -e&amp;lt;/code&amp;gt;, any metaarray failure marked the entire job as failed even when Hetzner backup succeeded. Removed entirely.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fixes applied:&#039;&#039;&#039;&lt;br /&gt;
# &amp;lt;code&amp;gt;restic repair index&amp;lt;/code&amp;gt; — consolidated 96 → 3 index files&lt;br /&gt;
# &amp;lt;code&amp;gt;restic forget --prune&amp;lt;/code&amp;gt; — cleaned up retention&lt;br /&gt;
# Added &amp;lt;code&amp;gt;-o sftp.args=&amp;quot;-oServerAliveInterval=10 -oServerAliveCountMax=36&amp;quot;&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;backup-to-hetzner.sh&amp;lt;/code&amp;gt; — 10s keepalives, 6-min tolerance&lt;br /&gt;
# Added &amp;lt;code&amp;gt;--retry-lock 10m&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;backup-to-hetzner.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
# Removed &amp;lt;code&amp;gt;backup-to-metaarray.sh&amp;lt;/code&amp;gt; and all meta-array config&lt;br /&gt;
# Added &amp;lt;code&amp;gt;/home/mnw&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/data/home&amp;lt;/code&amp;gt; to backup scope (dotfiles, Documents, source, SSH keys)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;If SSH timeouts recur:&#039;&#039;&#039; It means index files are accumulating again (prune is failing silently). Run:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup sh -c &#039;RESTIC_REPOSITORY=&amp;quot;$HETZNER_REPO&amp;quot; RESTIC_PASSWORD=&amp;quot;$HETZNER_PASSWORD&amp;quot; restic repair index&#039;&lt;br /&gt;
docker exec restic-backup sh -c &#039;RESTIC_REPOSITORY=&amp;quot;$HETZNER_REPO&amp;quot; RESTIC_PASSWORD=&amp;quot;$HETZNER_PASSWORD&amp;quot; restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
=== 2026-02-16 — First Manual Test ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Test:&#039;&#039;&#039; Restore files from latest snapshot (ae89d11d), compare against live originals.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! File&lt;br /&gt;
! Restored&lt;br /&gt;
! Compared&lt;br /&gt;
! Result&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-chat/synapse/homeserver.yaml&amp;lt;/code&amp;gt;&lt;br /&gt;
| 3,022 bytes&lt;br /&gt;
| vs live&lt;br /&gt;
| IDENTICAL&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-chat/synapse/signing.key&amp;lt;/code&amp;gt;&lt;br /&gt;
| 59 bytes&lt;br /&gt;
| vs live&lt;br /&gt;
| IDENTICAL&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-chat/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
| 3,232 bytes&lt;br /&gt;
| vs live&lt;br /&gt;
| IDENTICAL&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-chat/bridges/whatsapp/config.yaml&amp;lt;/code&amp;gt;&lt;br /&gt;
| 33,990 bytes&lt;br /&gt;
| vs live&lt;br /&gt;
| IDENTICAL&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;developer/SystemResiliency/STATUS.md&amp;lt;/code&amp;gt;&lt;br /&gt;
| Restored&lt;br /&gt;
| pre-edit version&lt;br /&gt;
| Expected diff (edited today)&lt;br /&gt;
|-&lt;br /&gt;
| DB dump: &amp;lt;code&amp;gt;immich-20260213.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| 463 MB&lt;br /&gt;
| gunzip -t&lt;br /&gt;
| VALID&lt;br /&gt;
|-&lt;br /&gt;
| DB dump: &amp;lt;code&amp;gt;matrix-20260215.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| 1 MB&lt;br /&gt;
| gunzip -t&lt;br /&gt;
| VALID&lt;br /&gt;
|-&lt;br /&gt;
| DB dump: &amp;lt;code&amp;gt;authentik-20260213.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| 16 MB&lt;br /&gt;
| gunzip -t&lt;br /&gt;
| VALID&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Issues Found:&#039;&#039;&#039;&lt;br /&gt;
* DB dumps from Feb 14-15 were 20 bytes (empty) — container&#039;s &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt; was calling &amp;lt;code&amp;gt;dump-databases.sh&amp;lt;/code&amp;gt; inside the container where &amp;lt;code&amp;gt;docker&amp;lt;/code&amp;gt; CLI doesn&#039;t exist, overwriting the good host-side dumps.&lt;br /&gt;
* &#039;&#039;&#039;Fixed:&#039;&#039;&#039; Deployed updated &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt; that verifies dumps instead of re-dumping. Fresh dumps created manually and confirmed valid.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Verdict:&#039;&#039;&#039; PASS — restore process works. Files are intact and match live originals.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=BackupsDR/Overview&amp;diff=25</id>
		<title>BackupsDR/Overview</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=BackupsDR/Overview&amp;diff=25"/>
		<updated>2026-07-05T18:18:36Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from backup-stack/HOW_IT_WORKS.md + DISASTER_RECOVERY.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `backup-stack/HOW_IT_WORKS.md` and `DISASTER_RECOVERY.md`. Canonical DR home is `~/Developer/DisasterRecovery/` — this page is the operational summary, not the full cold-start bootstrap guide.&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= How the Backup System Works =&lt;br /&gt;
&lt;br /&gt;
A beginner-friendly guide to understanding your new backup system.&lt;br /&gt;
&lt;br /&gt;
== The Big Picture ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
Your Server (stronghold)&lt;br /&gt;
         │&lt;br /&gt;
         │ 1. Dump databases to files&lt;br /&gt;
         ▼&lt;br /&gt;
   ┌─────────────┐&lt;br /&gt;
   │ PostgreSQL  │──► .sql.gz files&lt;br /&gt;
   │ Containers  │    (in /library/backup-stack/dumps/)&lt;br /&gt;
   └─────────────┘&lt;br /&gt;
         │&lt;br /&gt;
         │ 2. Restic reads all your data&lt;br /&gt;
         ▼&lt;br /&gt;
   ┌─────────────┐&lt;br /&gt;
   │   Restic    │──► Deduplicates, compresses, encrypts&lt;br /&gt;
   │  Container  │&lt;br /&gt;
   └─────────────┘&lt;br /&gt;
         │&lt;br /&gt;
         │ 3. Uploads to cloud storage&lt;br /&gt;
         ▼&lt;br /&gt;
   ┌─────────────┐&lt;br /&gt;
   │   Hetzner   │  Your encrypted backups live here&lt;br /&gt;
   │ Storage Box │  (in Germany, €3.81/month)&lt;br /&gt;
   └─────────────┘&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== What Gets Backed Up ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! What&lt;br /&gt;
! Where It Lives&lt;br /&gt;
! Size&lt;br /&gt;
! Why It Matters&lt;br /&gt;
|-&lt;br /&gt;
| Authentik DB&lt;br /&gt;
| PostgreSQL dump&lt;br /&gt;
| 14MB&lt;br /&gt;
| Your SSO - lose this, lose all logins&lt;br /&gt;
|-&lt;br /&gt;
| Vaultwarden&lt;br /&gt;
| SQLite file&lt;br /&gt;
| 356KB&lt;br /&gt;
| Your passwords!&lt;br /&gt;
|-&lt;br /&gt;
| Immich photos&lt;br /&gt;
| /raid1/.../Immich-Photos&lt;br /&gt;
| 149GB&lt;br /&gt;
| Family photos&lt;br /&gt;
|-&lt;br /&gt;
| Immich DB&lt;br /&gt;
| PostgreSQL dump&lt;br /&gt;
| 437MB&lt;br /&gt;
| Photo metadata, faces, albums&lt;br /&gt;
|-&lt;br /&gt;
| Jellyfin&lt;br /&gt;
| /var/lib/jellyfin&lt;br /&gt;
| 92GB&lt;br /&gt;
| Watch history, metadata&lt;br /&gt;
|-&lt;br /&gt;
| Nextcloud&lt;br /&gt;
| Config + PostgreSQL&lt;br /&gt;
| ~1GB&lt;br /&gt;
| Files, calendar, contacts&lt;br /&gt;
|-&lt;br /&gt;
| Open-WebUI&lt;br /&gt;
| SQLite + uploads&lt;br /&gt;
| 1.7GB&lt;br /&gt;
| AI chat history&lt;br /&gt;
|-&lt;br /&gt;
| Memos, Linkwarden, Mailcow&lt;br /&gt;
| Various&lt;br /&gt;
| Small&lt;br /&gt;
| Notes, bookmarks, email&lt;br /&gt;
|-&lt;br /&gt;
| MediaWiki&lt;br /&gt;
| Config bind mount + MariaDB dump + images volume&lt;br /&gt;
| ~300KB&lt;br /&gt;
| The wilsoz wiki — infra docs, runbooks, ADRs&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== The Components ==&lt;br /&gt;
&lt;br /&gt;
=== 1. Restic Container (&amp;lt;code&amp;gt;restic-backup&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
A small container that knows how to:&lt;br /&gt;
* Read your data from mounted directories&lt;br /&gt;
* Encrypt everything with your password&lt;br /&gt;
* Upload to Hetzner via SFTP&lt;br /&gt;
* Deduplicate (only upload changed data)&lt;br /&gt;
&lt;br /&gt;
=== 2. Database Dump Script (&amp;lt;code&amp;gt;host-dump-databases.sh&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
Runs on the host (not in container) because it needs Docker access.&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
authentik-postgresql → authentik-20260201.sql.gz&lt;br /&gt;
nextcloud-db         → nextcloud-20260201.sql.gz&lt;br /&gt;
immich_postgres      → immich-20260201.sql.gz&lt;br /&gt;
linkwarden_postgres  → linkwarden-20260201.sql.gz&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. Backup Script (&amp;lt;code&amp;gt;backup-to-hetzner.sh&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
Runs inside the container:&lt;br /&gt;
# Connects to Hetzner via SSH (port 23, key auth)&lt;br /&gt;
# Reads all your data directories&lt;br /&gt;
# Uploads new/changed files&lt;br /&gt;
# Prunes old snapshots (keeps 7 daily, 4 weekly, 6 monthly)&lt;br /&gt;
&lt;br /&gt;
=== 4. Systemd Timers ===&lt;br /&gt;
Schedules everything to run automatically:&lt;br /&gt;
* &#039;&#039;&#039;3:00 AM&#039;&#039;&#039; - Full backup&lt;br /&gt;
* &#039;&#039;&#039;6:00 AM&#039;&#039;&#039; - Integrity check&lt;br /&gt;
* &#039;&#039;&#039;Sunday 7:00 AM&#039;&#039;&#039; - Restore test&lt;br /&gt;
&lt;br /&gt;
== How Restic Works (The Magic) ==&lt;br /&gt;
&lt;br /&gt;
=== Deduplication ===&lt;br /&gt;
Restic breaks files into chunks. If you have 1000 photos and add 1 new one:&lt;br /&gt;
* First backup: uploads all 1000 photos (~149GB)&lt;br /&gt;
* Second backup: uploads only the 1 new photo (~5MB)&lt;br /&gt;
&lt;br /&gt;
=== Encryption ===&lt;br /&gt;
Everything is encrypted with your password before leaving your server.&lt;br /&gt;
* Hetzner can&#039;t read your data&lt;br /&gt;
* Without the password, backups are useless&lt;br /&gt;
* &#039;&#039;&#039;Password:&#039;&#039;&#039; &amp;lt;code&amp;gt;E+mVDSjj8VsB5Feklelsq/BAJ3FmMX3E&amp;lt;/code&amp;gt; (stored in .env)&lt;br /&gt;
&lt;br /&gt;
=== Snapshots ===&lt;br /&gt;
Each backup creates a &amp;quot;snapshot&amp;quot; - a point-in-time view of your data.&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
$ restic snapshots&lt;br /&gt;
ID        Time                 Host        Tags&lt;br /&gt;
─────────────────────────────────────────────────&lt;br /&gt;
a1b2c3d4  2026-02-01 23:50    stronghold  full&lt;br /&gt;
e5f6g7h8  2026-02-02 03:00    stronghold  full&lt;br /&gt;
...&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You can restore any snapshot, not just the latest.&lt;br /&gt;
&lt;br /&gt;
== How to Check on Backups ==&lt;br /&gt;
&lt;br /&gt;
=== Is the backup running? ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs restic-backup&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== How big is my backup repo? ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic stats&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== List all snapshots ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic snapshots&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Check repo integrity ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic check&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== How to Restore ==&lt;br /&gt;
&lt;br /&gt;
=== Restore everything (disaster recovery) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic restore latest --target /tmp/full-restore&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Restore one file ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/restore \&lt;br /&gt;
  --include &amp;quot;/data/vaultwarden/data/db.sqlite3&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Restore from specific date ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# List snapshots to find the ID&lt;br /&gt;
docker exec restic-backup restic snapshots&lt;br /&gt;
&lt;br /&gt;
# Restore that snapshot&lt;br /&gt;
docker exec restic-backup restic restore a1b2c3d4 --target /tmp/restore&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Important Files ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
/library/backup-stack/&lt;br /&gt;
├── docker-compose.yml    # Container definition&lt;br /&gt;
├── .env                  # Passwords and settings (SECRET!)&lt;br /&gt;
├── ssh-config            # SSH settings for Hetzner&lt;br /&gt;
├── dumps/                # Database dumps live here&lt;br /&gt;
│   ├── authentik-20260201.sql.gz&lt;br /&gt;
│   ├── immich-20260201.sql.gz&lt;br /&gt;
│   └── ...&lt;br /&gt;
├── scripts/&lt;br /&gt;
│   ├── host-dump-databases.sh  # Dumps DBs (runs on host)&lt;br /&gt;
│   ├── backup-to-hetzner.sh    # Main backup (runs in container)&lt;br /&gt;
│   └── validate-backups.sh     # Tests backups&lt;br /&gt;
└── systemd/              # Timer definitions&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== What Happens If... ==&lt;br /&gt;
&lt;br /&gt;
=== Power goes out during backup? ===&lt;br /&gt;
Restic is crash-safe. Next backup continues from where it left off.&lt;br /&gt;
&lt;br /&gt;
=== Hetzner goes down? ===&lt;br /&gt;
Your local data is fine. Backups resume when Hetzner is back.&lt;br /&gt;
&lt;br /&gt;
=== You delete a file by accident? ===&lt;br /&gt;
Restore it from a previous snapshot.&lt;br /&gt;
&lt;br /&gt;
=== Your server dies completely? ===&lt;br /&gt;
# Get new server&lt;br /&gt;
# Install Docker&lt;br /&gt;
# Connect to Hetzner&lt;br /&gt;
# Run &amp;lt;code&amp;gt;restic restore latest&amp;lt;/code&amp;gt;&lt;br /&gt;
# Rebuild containers with restored data&lt;br /&gt;
&lt;br /&gt;
=== You forget the password? ===&lt;br /&gt;
&#039;&#039;&#039;Game over.&#039;&#039;&#039; The backups are encrypted. No password = no data.&lt;br /&gt;
Save it somewhere safe (Vaultwarden, password manager, printed paper in safe).&lt;br /&gt;
&lt;br /&gt;
== Costs ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Cost&lt;br /&gt;
|-&lt;br /&gt;
| Hetzner Storage Box 1TB&lt;br /&gt;
| €3.81/month&lt;br /&gt;
|-&lt;br /&gt;
| Everything else&lt;br /&gt;
| Free (self-hosted)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Every night at 3 AM:&#039;&#039;&#039;&lt;br /&gt;
** Databases get dumped to .sql.gz files&lt;br /&gt;
** Restic encrypts and uploads everything to Germany&lt;br /&gt;
** Old backups get cleaned up automatically&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Every morning at 6 AM:&#039;&#039;&#039;&lt;br /&gt;
** System checks backup integrity&lt;br /&gt;
** Alerts you via ntfy if something&#039;s wrong&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Every Sunday:&#039;&#039;&#039;&lt;br /&gt;
** Does a test restore to verify backups actually work&lt;br /&gt;
&lt;br /&gt;
Your data is now protected. Sleep well.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
= Disaster Recovery Runbook =&lt;br /&gt;
&lt;br /&gt;
= Disaster Recovery Runbook =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Last Updated:&#039;&#039;&#039; 2026-06-21&lt;br /&gt;
&#039;&#039;&#039;Print this and store in a safe place.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
:&#039;&#039;&#039;Keep DR current:&#039;&#039;&#039; after adding/changing/removing any service, run&lt;br /&gt;
:&amp;lt;code&amp;gt;scripts/dr-drift-check.sh&amp;lt;/code&amp;gt; and act on whatever it flags. See&lt;br /&gt;
:&amp;lt;code&amp;gt;DR_MAINTENANCE.md&amp;lt;/code&amp;gt; for the full workflow. Don&#039;t skip this — gradual drift&lt;br /&gt;
:is how DR plans silently rot until the day you actually need them.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
&lt;br /&gt;
=== Stronghold (primary) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Hetzner Storage Box&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;u540853.your-storagebox.de&amp;lt;/code&amp;gt; port &#039;&#039;&#039;23&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Repo path&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;sftp:u540853@u540853.your-storagebox.de:./backups&amp;lt;/code&amp;gt; (port 23 comes from SSH config, &#039;&#039;&#039;not&#039;&#039;&#039; the URL — see note)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;SSH Key&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.ssh/id_rsa&amp;lt;/code&amp;gt; (on stronghold)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Restic Password&#039;&#039;&#039;&lt;br /&gt;
| In &amp;lt;code&amp;gt;/library/backup-stack/.env&amp;lt;/code&amp;gt; (&amp;lt;code&amp;gt;HETZNER_PASSWORD&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Backup Schedule&#039;&#039;&#039;&lt;br /&gt;
| Daily 03:00 CST&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Retention&#039;&#039;&#039;&lt;br /&gt;
| 7 daily, 4 weekly, 6 monthly&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== VPS (wilsoz.com) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Hetzner Storage Box&#039;&#039;&#039;&lt;br /&gt;
| same box, separate repo&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Repo path&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;sftp:u540853@u540853.your-storagebox.de:./backups-vps&amp;lt;/code&amp;gt; (port 23 comes from SSH config, &#039;&#039;&#039;not&#039;&#039;&#039; the URL — see note)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;SSH Key&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.ssh/id_ed25519&amp;lt;/code&amp;gt; (on VPS)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Restic Password&#039;&#039;&#039;&lt;br /&gt;
| same &amp;lt;code&amp;gt;HETZNER_PASSWORD&amp;lt;/code&amp;gt; — stored in &amp;lt;code&amp;gt;/etc/restic-vps.env&amp;lt;/code&amp;gt; on VPS&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Backup Schedule&#039;&#039;&#039;&lt;br /&gt;
| Daily 04:30 UTC (offset from stronghold)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Retention&#039;&#039;&#039;&lt;br /&gt;
| 7 daily, 4 weekly, 6 monthly&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Covers&#039;&#039;&#039;&lt;br /&gt;
| Forgejo data + repos, Atuin (pg_dump), ntfy, nginx, Let&#039;s Encrypt certs, &amp;lt;code&amp;gt;/opt/&amp;lt;/code&amp;gt; custom scripts&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
:&#039;&#039;&#039;⚠️ SFTP repo URL gotcha (port vs. path):&#039;&#039;&#039; In restic&#039;s single-colon SFTP form&lt;br /&gt;
:(&amp;lt;code&amp;gt;sftp:user@host:path&amp;lt;/code&amp;gt;) &#039;&#039;&#039;everything after the host colon is the repository path&#039;&#039;&#039; —&lt;br /&gt;
:there is no port in the URL. The storage box&#039;s port &#039;&#039;&#039;23&#039;&#039;&#039; must be set in SSH config&lt;br /&gt;
:(&amp;lt;code&amp;gt;~/.ssh/config&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;Port 23&amp;lt;/code&amp;gt; for host &amp;lt;code&amp;gt;u540853.your-storagebox.de&amp;lt;/code&amp;gt;), which both&lt;br /&gt;
:stronghold and the &amp;lt;code&amp;gt;restic-backup&amp;lt;/code&amp;gt; container already have. Writing the port into the&lt;br /&gt;
:URL as &amp;lt;code&amp;gt;:23/backups&amp;lt;/code&amp;gt; makes restic look for a repo at the literal path &amp;lt;code&amp;gt;23/backups&amp;lt;/code&amp;gt; and&lt;br /&gt;
:fail with &#039;&#039;&amp;quot;repository does not exist&amp;quot;&#039;&#039;. Always use the relative &amp;lt;code&amp;gt;:./backups&amp;lt;/code&amp;gt; /&lt;br /&gt;
:&amp;lt;code&amp;gt;:./backups-vps&amp;lt;/code&amp;gt; form. (This exact mistake caused false &amp;quot;VPS: no snapshots found&amp;quot;&lt;br /&gt;
:alerts from the n8n &#039;&#039;Backup Status Alert (SSH)&#039;&#039; workflow until 2026-06-29.)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== What&#039;s Backed Up ==&lt;br /&gt;
&lt;br /&gt;
=== Service Data (files) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Path in Backup&lt;br /&gt;
! Source on Host&lt;br /&gt;
! Contents&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/authentik&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/authentik-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| SSO/identity provider config&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/vaultwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/vaultwarden-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Password vault (SQLite DB + attachments)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/nextcloud&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/nextcloud-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| File sync config + data&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/immich-config&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/immich-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Photo manager config&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/immich-photos&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/raid1/Consolidated/Picture/Immich-Photos/&amp;lt;/code&amp;gt;&lt;br /&gt;
| All photos (149G)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/jellyfin&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Media server metadata (92G)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/jellyfin-etc&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/etc/jellyfin/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Jellyfin system config&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/memos&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/memo-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Notes app&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/linkwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/linkwarden-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Bookmark manager&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/open-webui&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/open-webui-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| AI chat interface&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/matrix-chat&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/matrix-chat/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Synapse + bridge configs + signing key&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/stalwart&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/stalwart-app/data/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Mail server data&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/snappymail&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/snappymail-app/data/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Webmail data&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/mailcow&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/opt/mailcow-dockerized/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Legacy mail (to be archived)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/home&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/home/mnw/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Home dir: dotfiles, scripts, Documents, source, SSH/GPG keys (selective excludes)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/developer&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/home/mnw/Developer/&amp;lt;/code&amp;gt;&lt;br /&gt;
| All dev projects (7.9G) — also covered by &amp;lt;code&amp;gt;/data/home&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/beets&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/raid1/my-tunes/.beets/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Music library DB&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/openbao&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao secret store — &#039;&#039;&#039;incl. &amp;lt;code&amp;gt;.init-output.json&amp;lt;/code&amp;gt; (root token + unseal key) and &amp;lt;code&amp;gt;.scripts-token&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;.claude-token&amp;lt;/code&amp;gt;.&#039;&#039;&#039; Bootstrap-critical: must be restored and unsealed &#039;&#039;before&#039;&#039; services that depend on secrets.&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/ezbookkeeping&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/ezbookkeeping-app/data/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Personal finance tracker (SQLite + uploads)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/data/omada&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/omada-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Omada SDN controller — switch/AP config &amp;amp; topology&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Database Dumps (in &amp;lt;code&amp;gt;/dumps/&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! File Pattern&lt;br /&gt;
! Source Container&lt;br /&gt;
! Database&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-app_postgresql_1&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik identity DB&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;nextcloud-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;nextcloud-db&amp;lt;/code&amp;gt;&lt;br /&gt;
| Nextcloud DB&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;immich-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;immich_postgres&amp;lt;/code&amp;gt;&lt;br /&gt;
| Immich photo DB (~460MB compressed)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;linkwarden-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;linkwarden_postgres&amp;lt;/code&amp;gt;&lt;br /&gt;
| Linkwarden bookmark DB&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-postgres&amp;lt;/code&amp;gt;&lt;br /&gt;
| Synapse + bridge DBs (~1MB compressed)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== NOT Backed Up (re-acquirable) ===&lt;br /&gt;
&lt;br /&gt;
* Jellyfin media files (movies, TV)&lt;br /&gt;
* Ollama models (~20G, re-downloadable)&lt;br /&gt;
* Steam games&lt;br /&gt;
* VirtualBox VMs&lt;br /&gt;
* node_modules, .venv, .git/objects&lt;br /&gt;
* Wyoming voice models (&amp;lt;code&amp;gt;piper-data-app&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;whisper-data-app&amp;lt;/code&amp;gt;) — redownloadable&lt;br /&gt;
* Prometheus metric history, Grafana dashboards data, uptime-kuma — re-buildable from compose&lt;br /&gt;
&lt;br /&gt;
=== ⚠️ Known Gaps — Data Not Currently Backed Up ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Severity&lt;br /&gt;
! What&lt;br /&gt;
! Where&lt;br /&gt;
! Notes&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HIGH&#039;&#039;&#039;&lt;br /&gt;
| AudioBookshelf config + metadata&lt;br /&gt;
| &amp;lt;code&amp;gt;/usr/share/audiobookshelf/config/&amp;lt;/code&amp;gt; (38M), &amp;lt;code&amp;gt;/usr/share/audiobookshelf/metadata/&amp;lt;/code&amp;gt; (936M)&lt;br /&gt;
| systemd service, not Docker — not yet bind-mounted into restic container. Lose library DB, progress, and cover art on rebuild.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Previously flagged gaps (all closed as of 2026-06-21): Nextcloud user files, Matrix media, Mealie, n8n named volumes, uptime-kuma, Grafana, AdGuard — all now covered by bind mounts in the restic container compose.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Recovery Scenarios ==&lt;br /&gt;
&lt;br /&gt;
=== Scenario 1: Single Service Failure ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example:&#039;&#039;&#039; Immich database corrupted, need to restore just Immich.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Stop the service&lt;br /&gt;
cd /library/immich-app &amp;amp;&amp;amp; docker compose down&lt;br /&gt;
&lt;br /&gt;
# 2. List available snapshots&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; restic snapshots&#039;&lt;br /&gt;
&lt;br /&gt;
# 3. Restore service data from latest snapshot&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; \&lt;br /&gt;
   restic restore latest --target /tmp/restore --include /data/immich-config&#039;&lt;br /&gt;
&lt;br /&gt;
# 4. Copy restored data to host&lt;br /&gt;
docker cp restic-backup:/tmp/restore/data/immich-config /tmp/immich-restore&lt;br /&gt;
&lt;br /&gt;
# 5. Replace the broken data&lt;br /&gt;
mv /library/immich-app /library/immich-app.broken&lt;br /&gt;
mv /tmp/immich-restore /library/immich-app&lt;br /&gt;
&lt;br /&gt;
# 6. Restore the database dump&lt;br /&gt;
docker cp restic-backup:/tmp/restore/dumps/immich-YYYYMMDD.sql.gz /tmp/&lt;br /&gt;
gunzip /tmp/immich-YYYYMMDD.sql.gz&lt;br /&gt;
# Start just the database container&lt;br /&gt;
docker compose up -d immich_postgres&lt;br /&gt;
# Wait for it to be ready, then restore&lt;br /&gt;
docker exec -i immich_postgres psql -U postgres immich &amp;lt; /tmp/immich-YYYYMMDD.sql&lt;br /&gt;
&lt;br /&gt;
# 7. Start the full service&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 8. Clean up&lt;br /&gt;
docker exec restic-backup rm -rf /tmp/restore&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario 2: Full Server Rebuild ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Starting from:&#039;&#039;&#039; Fresh Linux install on new hardware.&lt;br /&gt;
&lt;br /&gt;
==== Bootstrap Order (DO NOT SHUFFLE) ====&lt;br /&gt;
&lt;br /&gt;
Many services depend on OpenBao (secret store) and Authentik (SSO). Bring them&lt;br /&gt;
up in this order so each layer has its dependencies in place:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
1. Host: Docker, restic, Tailscale, SSH key&lt;br /&gt;
2. Restore everything from Hetzner via restic (raw files)&lt;br /&gt;
3. OpenBao  ← unseal it first; it holds tokens other services need&lt;br /&gt;
4. Authentik (DB dump → start) ← SSO for everything&lt;br /&gt;
5. Vaultwarden ← human-facing creds; needed for me-the-operator&lt;br /&gt;
6. Restore Claude Code access (bw CLI, MCP wrappers) ← so future-you can drive recovery&lt;br /&gt;
7. Postgres-backed services (Immich, Nextcloud, Matrix, Linkwarden): DB dump → start&lt;br /&gt;
8. Stalwart + SnappyMail (mail)&lt;br /&gt;
9. Everything else (n8n, Grafana, Mealie, etc.)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 1: Install Prerequisites ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Install Docker&lt;br /&gt;
curl -fsSL https://get.docker.com | sh&lt;br /&gt;
sudo usermod -aG docker mnw&lt;br /&gt;
&lt;br /&gt;
# Install restic&lt;br /&gt;
sudo apt install restic&lt;br /&gt;
&lt;br /&gt;
# Set Docker MTU for Tailscale compatibility&lt;br /&gt;
# Add to /etc/docker/daemon.json:&lt;br /&gt;
# {&amp;quot;mtu&amp;quot;: 1280}&lt;br /&gt;
sudo systemctl restart docker&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 2: Restore SSH Keys ====&lt;br /&gt;
&lt;br /&gt;
You need the SSH key to access Hetzner. If you have a copy:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p ~/.ssh&lt;br /&gt;
# Copy id_rsa from your backup/safe location&lt;br /&gt;
chmod 600 ~/.ssh/id_rsa&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you don&#039;t have the key, generate a new one and add it to Hetzner:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ssh-keygen -t rsa -b 4096&lt;br /&gt;
# Add public key via Hetzner Robot panel → Storage Box → SSH Keys&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 3: Bootstrap Restic ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create the backup stack directory&lt;br /&gt;
sudo mkdir -p /library/backup-stack&lt;br /&gt;
cd /library/backup-stack&lt;br /&gt;
&lt;br /&gt;
# Create .env with Hetzner credentials:&lt;br /&gt;
cat &amp;gt; .env &amp;lt;&amp;lt; &#039;EOF&#039;&lt;br /&gt;
HETZNER_REPO=sftp:u540853@u540853.your-storagebox.de:23/backups&lt;br /&gt;
HETZNER_PASSWORD=&amp;lt;your-restic-password&amp;gt;&lt;br /&gt;
NTFY_TOKEN=tk_0fhigfiglbjw9r8sk9jv4hpr7q67f&lt;br /&gt;
EOF&lt;br /&gt;
&lt;br /&gt;
# Test connection&lt;br /&gt;
export $(grep -v &#039;^#&#039; .env | xargs)&lt;br /&gt;
restic -r &amp;quot;$HETZNER_REPO&amp;quot; snapshots&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 4: Restore Everything ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create target directories&lt;br /&gt;
sudo mkdir -p /library /raid1/Consolidated/Picture/Immich-Photos&lt;br /&gt;
&lt;br /&gt;
# Restore all service data&lt;br /&gt;
restic -r &amp;quot;$HETZNER_REPO&amp;quot; restore latest --target /&lt;br /&gt;
&lt;br /&gt;
# This puts files at their original paths:&lt;br /&gt;
#   /data/authentik → needs moving to /library/authentik-app/&lt;br /&gt;
#   /data/developer → needs moving to /home/mnw/Developer/&lt;br /&gt;
#   etc.&lt;br /&gt;
&lt;br /&gt;
# Move restored data to correct locations&lt;br /&gt;
sudo mv /data/authentik /library/authentik-app&lt;br /&gt;
sudo mv /data/vaultwarden /library/vaultwarden-app&lt;br /&gt;
sudo mv /data/nextcloud /library/nextcloud-app&lt;br /&gt;
sudo mv /data/immich-config /library/immich-app&lt;br /&gt;
sudo mv /data/immich-photos/* /raid1/Consolidated/Picture/Immich-Photos/&lt;br /&gt;
sudo mv /data/jellyfin /var/lib/jellyfin&lt;br /&gt;
sudo mv /data/jellyfin-etc /etc/jellyfin&lt;br /&gt;
sudo mv /data/memos /library/memo-app&lt;br /&gt;
sudo mv /data/linkwarden /library/linkwarden-app&lt;br /&gt;
sudo mv /data/open-webui /library/open-webui-app&lt;br /&gt;
sudo mv /data/matrix-chat /library/matrix-chat&lt;br /&gt;
sudo mv /data/stalwart /library/stalwart-app/data&lt;br /&gt;
sudo mv /data/snappymail /library/snappymail-app/data&lt;br /&gt;
sudo mv /data/developer /home/mnw/Developer&lt;br /&gt;
sudo mv /data/beets /raid1/my-tunes/.beets&lt;br /&gt;
sudo chown -R mnw:mnw /home/mnw/Developer&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 4b: Bootstrap OpenBao (Secret Store) — REQUIRED before DBs ====&lt;br /&gt;
&lt;br /&gt;
OpenBao holds tokens and secrets that downstream services + scripts read at startup.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Move restored data into place&lt;br /&gt;
sudo mv /data/openbao /library/openbao-app&lt;br /&gt;
sudo chown -R 1000:1000 /library/openbao-app  # OpenBao runs as uid 1000&lt;br /&gt;
&lt;br /&gt;
# 2. Start the container&lt;br /&gt;
cd /library/openbao-app &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 3. Unseal — uses the unseal key from .init-output.json (restored from backup)&lt;br /&gt;
sudo /usr/local/bin/openbao-unseal.sh&lt;br /&gt;
# If that script isn&#039;t restored yet, manually:&lt;br /&gt;
#   ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)&lt;br /&gt;
#   UNSEAL_KEY=$(jq -r .unseal_keys_b64[0] /library/openbao-app/.init-output.json)&lt;br /&gt;
#   docker exec openbao bao operator unseal &amp;quot;$UNSEAL_KEY&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# 4. Install the auto-unseal systemd unit so it survives reboots&lt;br /&gt;
sudo install -m 0755 scripts/openbao/openbao-unseal.sh /usr/local/bin/&lt;br /&gt;
sudo install -m 0644 scripts/openbao/openbao-unseal.service /etc/systemd/system/&lt;br /&gt;
sudo systemctl daemon-reload &amp;amp;&amp;amp; sudo systemctl enable --now openbao-unseal.service&lt;br /&gt;
&lt;br /&gt;
# 5. Verify by reading a known secret&lt;br /&gt;
TOKEN=$(cat /library/openbao-app/.scripts-token)&lt;br /&gt;
curl -sf http://127.0.0.1:8200/v1/secret/data/ntfy -H &amp;quot;X-Vault-Token: $TOKEN&amp;quot; | jq .data.data&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;If &amp;lt;code&amp;gt;.init-output.json&amp;lt;/code&amp;gt; is lost:&#039;&#039;&#039; OpenBao is unrecoverable without it. Print&lt;br /&gt;
it now (or keep it in a sealed envelope) — it contains the root token AND the&lt;br /&gt;
unseal key. Without these, you cannot decrypt anything in the Raft store.&lt;br /&gt;
&lt;br /&gt;
==== Step 5: Restore Databases ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# For each service, start just the DB container, restore the dump, then start the rest.&lt;br /&gt;
# Example for Authentik:&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
docker compose up -d postgresql&lt;br /&gt;
sleep 10&lt;br /&gt;
gunzip -c /dumps/authentik-YYYYMMDD.sql.gz | docker exec -i authentik-app_postgresql_1 psql -U authentik authentik&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Repeat for: nextcloud, immich, linkwarden, matrix&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 6: Restore System Services ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Jellyfin (systemd, not Docker)&lt;br /&gt;
sudo apt install jellyfin&lt;br /&gt;
# Data already restored to /var/lib/jellyfin and /etc/jellyfin&lt;br /&gt;
&lt;br /&gt;
# AudioBookshelf&lt;br /&gt;
# Install from package, data at /usr/share/audiobookshelf&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Step 6b: Restore Claude Code Access (Vaultwarden MCP) ====&lt;br /&gt;
&lt;br /&gt;
Once Vaultwarden is up (Step 5 + nginx routed), restore the bw CLI and MCP&lt;br /&gt;
wrappers so a Claude session can help you finish recovery from this point on.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Install bw CLI (snap is the official Bitwarden source)&lt;br /&gt;
sudo snap install bw&lt;br /&gt;
&lt;br /&gt;
# 2. Install Bitwarden MCP server (npm, globally)&lt;br /&gt;
npm install -g @bitwarden/mcp-server&lt;br /&gt;
&lt;br /&gt;
# 3. Restore wrappers — these are in /data/home/.config/claude/ from backup&lt;br /&gt;
mkdir -p ~/.config/claude&lt;br /&gt;
sudo cp /data/home/.config/claude/{vaultwarden.env,bw-mcp-start.sh,bw-get.py} ~/.config/claude/&lt;br /&gt;
chmod 600 ~/.config/claude/vaultwarden.env&lt;br /&gt;
chmod 700 ~/.config/claude/bw-mcp-start.sh ~/.config/claude/bw-get.py&lt;br /&gt;
&lt;br /&gt;
# 4. Verify bw can talk to Vaultwarden (not the public Bitwarden cloud!)&lt;br /&gt;
bw config server  # MUST print https://vault.wilsoz.com — if not, run: bw config server https://vault.wilsoz.com&lt;br /&gt;
bw login --apikey  # uses BW_CLIENTID + BW_CLIENTSECRET from env&lt;br /&gt;
&lt;br /&gt;
# 5. Wire MCPs into ~/.claude.json (vault + bitwarden entries)&lt;br /&gt;
# See services/VAULTWARDEN.md for the exact JSON structure if it needs to be rebuilt.&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
See &amp;lt;code&amp;gt;services/VAULTWARDEN.md&amp;lt;/code&amp;gt; for full setup including the &amp;lt;code&amp;gt;mcp-server-bitwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
symlink bug workaround.&lt;br /&gt;
&lt;br /&gt;
==== Step 7: Re-establish Networking ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Install Tailscale&lt;br /&gt;
curl -fsSL https://tailscale.com/install.sh | sh&lt;br /&gt;
sudo tailscale up&lt;br /&gt;
&lt;br /&gt;
# VPS nginx configs point to Tailscale IPs — update if IP changes&lt;br /&gt;
# VPS SSH: ssh -p 1981 mnw@wilsoz.com&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario 3: Restore Specific Files ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Restore a single file from a specific snapshot&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; \&lt;br /&gt;
   restic restore SNAPSHOT_ID --target /tmp/restore --include /data/developer/SystemResiliency/STATUS.md&#039;&lt;br /&gt;
&lt;br /&gt;
docker cp restic-backup:/tmp/restore/data/developer/SystemResiliency/STATUS.md /tmp/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario 4: Matrix Chat Recovery ===&lt;br /&gt;
&lt;br /&gt;
Matrix requires special care — the signing key is critical.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Restore Matrix data&lt;br /&gt;
restic restore latest --target /tmp/restore --include /data/matrix-chat&lt;br /&gt;
&lt;br /&gt;
# 2. Critical files:&lt;br /&gt;
#    - synapse/signing.key — IRREPLACEABLE, needed for federation&lt;br /&gt;
#    - synapse/homeserver.yaml — server config&lt;br /&gt;
#    - bridges/*/config.yaml — bridge configs&lt;br /&gt;
#    - bridges/*/registration.yaml — bridge registrations&lt;br /&gt;
&lt;br /&gt;
# 3. Restore database&lt;br /&gt;
gunzip -c /dumps/matrix-YYYYMMDD.sql.gz | docker exec -i matrix-postgres psql -U synapse&lt;br /&gt;
&lt;br /&gt;
# 4. Restart stack&lt;br /&gt;
cd /library/matrix-chat &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Verification Commands ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# List all snapshots&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; restic snapshots&#039;&lt;br /&gt;
&lt;br /&gt;
# Check repository integrity&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; restic check&#039;&lt;br /&gt;
&lt;br /&gt;
# Check repo size on Hetzner&lt;br /&gt;
ssh -p 23 u540853@u540853.your-storagebox.de &amp;quot;du -sh backups/&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# List files in a snapshot&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; \&lt;br /&gt;
   restic ls latest /data/matrix-chat&#039;&lt;br /&gt;
&lt;br /&gt;
# Verify a DB dump&lt;br /&gt;
gunzip -t /library/backup-stack/dumps/immich-YYYYMMDD.sql.gz &amp;amp;&amp;amp; echo &amp;quot;OK&amp;quot; || echo &amp;quot;CORRUPT&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Manual backup trigger&lt;br /&gt;
sudo /library/backup-stack/scripts/host-dump-databases.sh&lt;br /&gt;
docker exec restic-backup /scripts/backup-to-hetzner.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Automated Monitoring ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Check&lt;br /&gt;
! Frequency&lt;br /&gt;
! Alert Channel&lt;br /&gt;
|-&lt;br /&gt;
| Backup age (n8n)&lt;br /&gt;
| Every 6h&lt;br /&gt;
| ntfy (nc700x-alerts) + Matrix&lt;br /&gt;
|-&lt;br /&gt;
| Integrity check (restic)&lt;br /&gt;
| Daily 06:00&lt;br /&gt;
| ntfy if FAIL&lt;br /&gt;
|-&lt;br /&gt;
| Restore test (restic)&lt;br /&gt;
| Weekly Sunday 07:00&lt;br /&gt;
| ntfy if FAIL&lt;br /&gt;
|-&lt;br /&gt;
| Disk space (n8n)&lt;br /&gt;
| Every 4h&lt;br /&gt;
| ntfy (nc700x-alerts) + Matrix&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Key Credentials ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Keep a copy of these offline:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Credential&lt;br /&gt;
! Location&lt;br /&gt;
|-&lt;br /&gt;
| Hetzner SSH key&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.ssh/id_rsa&amp;lt;/code&amp;gt; on stronghold&lt;br /&gt;
|-&lt;br /&gt;
| Restic password&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/backup-stack/.env&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Hetzner account&lt;br /&gt;
| Robot panel, u540853&lt;br /&gt;
|-&lt;br /&gt;
| ntfy token&lt;br /&gt;
| &amp;lt;code&amp;gt;tk_0fhigfiglbjw9r8sk9jv4hpr7q67f&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Authentik admin&lt;br /&gt;
| &amp;lt;code&amp;gt;https://key.wilsoz.com/if/admin/&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| VPS SSH&lt;br /&gt;
| &amp;lt;code&amp;gt;ssh -p 1981 mnw@wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Synapse shared secret&lt;br /&gt;
| In &amp;lt;code&amp;gt;homeserver.yaml&amp;lt;/code&amp;gt; line 50&lt;br /&gt;
|-&lt;br /&gt;
| Matrix signing key&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/matrix-chat/synapse/signing.key&amp;lt;/code&amp;gt; — &#039;&#039;&#039;IRREPLACEABLE&#039;&#039;&#039;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Restore Test Log ==&lt;br /&gt;
&lt;br /&gt;
=== 2026-02-23 — False Failure Alerts + Upload Anomaly Detection ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem 1 — False failure alert after meta-array removal:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;code&amp;gt;validate-backups.sh&amp;lt;/code&amp;gt; still contained a &amp;lt;code&amp;gt;restic check&amp;lt;/code&amp;gt; block against &amp;lt;code&amp;gt;$METAARRAY_REPO&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;$METAARRAY_PASSWORD&amp;lt;/code&amp;gt;. Both vars were removed from &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; when meta-array was dropped on 2026-02-22. Empty repo var → restic failed → &amp;lt;code&amp;gt;ERRORS++&amp;lt;/code&amp;gt; → ntfy &amp;lt;code&amp;gt;nc700x-alerts&amp;lt;/code&amp;gt; fired every day.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039; Removed the dead meta-array block from &amp;lt;code&amp;gt;validate-backups.sh&amp;lt;/code&amp;gt; (lines 44–52).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem 2 — Large upload on first run after container recreation:&#039;&#039;&#039;&lt;br /&gt;
Restic container was recreated during meta-array removal, clearing the local cache. Without cache, restic reported &amp;quot;no parent snapshot found&amp;quot; and re-scanned all 441,884 files (all showed as &amp;quot;new&amp;quot;). Chunk dedup still worked correctly — only 11.5 GiB of 314 GiB was actually uploaded. Confirmed by upload anomaly report: Stalwart ~8k new email files (ongoing Fastmail getmail import) + &amp;lt;code&amp;gt;/data/home&amp;lt;/code&amp;gt; (all new to the repo without a parent reference). Normal next run.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Enhancement — Upload anomaly detection:&#039;&#039;&#039;&lt;br /&gt;
Added &amp;lt;code&amp;gt;detect_upload_anomaly()&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt;:&lt;br /&gt;
* Tracks upload size per run in &amp;lt;code&amp;gt;/dumps/upload-history.txt&amp;lt;/code&amp;gt; (rolling 30 entries)&lt;br /&gt;
* If upload &amp;gt;2.5× rolling average AND &amp;gt;500 MiB, runs &amp;lt;code&amp;gt;restic diff &amp;lt;prev&amp;gt; &amp;lt;cur&amp;gt;&amp;lt;/code&amp;gt; and summarises changes by service path&lt;br /&gt;
* POSTs JSON payload to n8n webhook (&amp;lt;code&amp;gt;whO5LBZHQHCXXG9I&amp;lt;/code&amp;gt;) which formats and sends to Matrix&lt;br /&gt;
* ntfy not used — this is informational, not an emergency&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
=== 2026-02-22 — Recurring SSH Timeout Incident ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem:&#039;&#039;&#039; Backups failing with &amp;lt;code&amp;gt;subprocess ssh: Timeout&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;Fatal: unable to save snapshot: context canceled&amp;lt;/code&amp;gt;. Hetzner SFTP server drops idle connections (~5 min) during restic&#039;s local scan phase (22+ min). Compounded by 96 accumulated index files (documented threshold: 49) because prune was also failing.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Secondary cause removed:&#039;&#039;&#039; meta-array (ma.sdf.org) secondary backup target was unreliable. Since &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt; used &amp;lt;code&amp;gt;set -e&amp;lt;/code&amp;gt;, any metaarray failure marked the entire job as failed even when Hetzner backup succeeded. Removed entirely.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fixes applied:&#039;&#039;&#039;&lt;br /&gt;
# &amp;lt;code&amp;gt;restic repair index&amp;lt;/code&amp;gt; — consolidated 96 → 3 index files&lt;br /&gt;
# &amp;lt;code&amp;gt;restic forget --prune&amp;lt;/code&amp;gt; — cleaned up retention&lt;br /&gt;
# Added &amp;lt;code&amp;gt;-o sftp.args=&amp;quot;-oServerAliveInterval=10 -oServerAliveCountMax=36&amp;quot;&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;backup-to-hetzner.sh&amp;lt;/code&amp;gt; — 10s keepalives, 6-min tolerance&lt;br /&gt;
# Added &amp;lt;code&amp;gt;--retry-lock 10m&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;backup-to-hetzner.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
# Removed &amp;lt;code&amp;gt;backup-to-metaarray.sh&amp;lt;/code&amp;gt; and all meta-array config&lt;br /&gt;
# Added &amp;lt;code&amp;gt;/home/mnw&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/data/home&amp;lt;/code&amp;gt; to backup scope (dotfiles, Documents, source, SSH keys)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;If SSH timeouts recur:&#039;&#039;&#039; It means index files are accumulating again (prune is failing silently). Run:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec restic-backup sh -c &#039;RESTIC_REPOSITORY=&amp;quot;$HETZNER_REPO&amp;quot; RESTIC_PASSWORD=&amp;quot;$HETZNER_PASSWORD&amp;quot; restic repair index&#039;&lt;br /&gt;
docker exec restic-backup sh -c &#039;RESTIC_REPOSITORY=&amp;quot;$HETZNER_REPO&amp;quot; RESTIC_PASSWORD=&amp;quot;$HETZNER_PASSWORD&amp;quot; restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
=== 2026-02-16 — First Manual Test ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Test:&#039;&#039;&#039; Restore files from latest snapshot (ae89d11d), compare against live originals.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! File&lt;br /&gt;
! Restored&lt;br /&gt;
! Compared&lt;br /&gt;
! Result&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-chat/synapse/homeserver.yaml&amp;lt;/code&amp;gt;&lt;br /&gt;
| 3,022 bytes&lt;br /&gt;
| vs live&lt;br /&gt;
| IDENTICAL&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-chat/synapse/signing.key&amp;lt;/code&amp;gt;&lt;br /&gt;
| 59 bytes&lt;br /&gt;
| vs live&lt;br /&gt;
| IDENTICAL&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-chat/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
| 3,232 bytes&lt;br /&gt;
| vs live&lt;br /&gt;
| IDENTICAL&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;matrix-chat/bridges/whatsapp/config.yaml&amp;lt;/code&amp;gt;&lt;br /&gt;
| 33,990 bytes&lt;br /&gt;
| vs live&lt;br /&gt;
| IDENTICAL&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;developer/SystemResiliency/STATUS.md&amp;lt;/code&amp;gt;&lt;br /&gt;
| Restored&lt;br /&gt;
| pre-edit version&lt;br /&gt;
| Expected diff (edited today)&lt;br /&gt;
|-&lt;br /&gt;
| DB dump: &amp;lt;code&amp;gt;immich-20260213.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| 463 MB&lt;br /&gt;
| gunzip -t&lt;br /&gt;
| VALID&lt;br /&gt;
|-&lt;br /&gt;
| DB dump: &amp;lt;code&amp;gt;matrix-20260215.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| 1 MB&lt;br /&gt;
| gunzip -t&lt;br /&gt;
| VALID&lt;br /&gt;
|-&lt;br /&gt;
| DB dump: &amp;lt;code&amp;gt;authentik-20260213.sql.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| 16 MB&lt;br /&gt;
| gunzip -t&lt;br /&gt;
| VALID&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Issues Found:&#039;&#039;&#039;&lt;br /&gt;
* DB dumps from Feb 14-15 were 20 bytes (empty) — container&#039;s &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt; was calling &amp;lt;code&amp;gt;dump-databases.sh&amp;lt;/code&amp;gt; inside the container where &amp;lt;code&amp;gt;docker&amp;lt;/code&amp;gt; CLI doesn&#039;t exist, overwriting the good host-side dumps.&lt;br /&gt;
* &#039;&#039;&#039;Fixed:&#039;&#039;&#039; Deployed updated &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt; that verifies dumps instead of re-dumping. Fresh dumps created manually and confirmed valid.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Verdict:&#039;&#039;&#039; PASS — restore process works. Files are intact and match live originals.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Email/Architecture&amp;diff=24</id>
		<title>Email/Architecture</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Email/Architecture&amp;diff=24"/>
		<updated>2026-07-05T18:18:17Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from TODO.md email section&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `TODO.md` (Email section) and cross-referenced with [[Services/Stalwart]]&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
See also: &#039;&#039;&#039;[[Services/Stalwart]]&#039;&#039;&#039; for the full mail server operational reference (admin API, ports, sieve rules, gotchas). This page covers the broader mail architecture goals and phase history.&lt;br /&gt;
&lt;br /&gt;
== Email (Stalwart Migration) ==&lt;br /&gt;
&lt;br /&gt;
=== Architecture goal: Multi-user family &amp;amp; friends mail server ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
Inbound:  Internet → MXRoute (per-user spool) → getmail → Stalwart (per-user inbox)&lt;br /&gt;
Outbound: User authenticates to Stalwart SMTP with own Authentik creds&lt;br /&gt;
          → Stalwart relays via user&#039;s own MXRoute credentials (per-user relay routes)&lt;br /&gt;
          → 400 msg/hr global rate limit to MXRoute enforced by Stalwart queue throttle&lt;br /&gt;
Domains:  craniumslows.com now. wilsoz.com → MXRoute once craniumslows validated.&lt;br /&gt;
Users:    Authentik LDAP → Stalwart. Custom attributes drive address provisioning.&lt;br /&gt;
Spam:     Server-wide Stalwart sieve → spam into each user&#039;s Junk&lt;br /&gt;
Sieve:    Per-user rules via ManageSieve (no rule affects another user)&lt;br /&gt;
Webmail:  SnappyMail at mymail.wilsoz.com&lt;br /&gt;
External: Gmail/iCloud/SDF as SnappyMail additional accounts (direct IMAP, not getmail)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== User provisioning model (Authentik custom LDAP attributes) ===&lt;br /&gt;
&lt;br /&gt;
Each Authentik user gets three custom attributes:&lt;br /&gt;
* &amp;lt;code&amp;gt;mail-name&amp;lt;/code&amp;gt; — primary local part (e.g. &amp;lt;code&amp;gt;joe&amp;lt;/code&amp;gt;)&lt;br /&gt;
* &amp;lt;code&amp;gt;mail-alias&amp;lt;/code&amp;gt; — secondary local part, optional (e.g. &amp;lt;code&amp;gt;joe.smith&amp;lt;/code&amp;gt;)&lt;br /&gt;
* &amp;lt;code&amp;gt;default-domain&amp;lt;/code&amp;gt; — which domain is the primary SnappyMail identity&lt;br /&gt;
&lt;br /&gt;
Groups control which domains are active:&lt;br /&gt;
* &amp;lt;code&amp;gt;craniumslows-email&amp;lt;/code&amp;gt; → @craniumslows.com addresses&lt;br /&gt;
* &amp;lt;code&amp;gt;wilsoz-email&amp;lt;/code&amp;gt; → @wilsoz.com addresses&lt;br /&gt;
&lt;br /&gt;
Address matrix = &amp;lt;code&amp;gt;{mail-name, mail-alias} × {domains from group memberships}&amp;lt;/code&amp;gt;&lt;br /&gt;
All addresses on a user deliver to one Stalwart inbox. Each address gets its own&lt;br /&gt;
MXRoute account + getmail .rc + per-user Stalwart relay route.&lt;br /&gt;
&lt;br /&gt;
Address changes: old address becomes a forwarder to the new one. Inbox never moves.&lt;br /&gt;
&lt;br /&gt;
=== Where the real mail data lives ===&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Location&lt;br /&gt;
! What&lt;br /&gt;
! Size&lt;br /&gt;
! Action&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/email/selfhosted/mail/wilsoz.com/marcus/Maildir/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Primary mailbox — Mailcow/Dovecot, ~100 nested folders&lt;br /&gt;
| ~8.9GB&lt;br /&gt;
| &#039;&#039;&#039;Import to Stalwart — in progress&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/email/selfhosted/mail/wilsoz.com/marcus/Orig-Maildir/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Pre-Mailcow import (iRedMail/Gmail era)&lt;br /&gt;
| (subset)&lt;br /&gt;
| Verify not unique before discarding&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/email/selfhosted/mail/craniumslows.com/marcus/Maildir/&amp;lt;/code&amp;gt;&lt;br /&gt;
| craniumslows.com mailbox&lt;br /&gt;
| 152K&lt;br /&gt;
| Import to Stalwart&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/mail/mnw&amp;lt;/code&amp;gt;&lt;br /&gt;
| Old iRedMail mbox (pre-Mailcow era)&lt;br /&gt;
| 37MB&lt;br /&gt;
| Import after primary migration&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/opt/mailcow-dockerized/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Mailcow app stack only&lt;br /&gt;
| 49MB&lt;br /&gt;
| Remove after migration verified&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Phase 3 (Mail Import) — COMPLETE ✓ ===&lt;br /&gt;
* [x] &#039;&#039;&#039;Import marcus@wilsoz.com&#039;&#039;&#039; — 137,335 messages from Mailcow Maildir, all folders preserved&lt;br /&gt;
* [x] &#039;&#039;&#039;Import marcus@craniumslows.com&#039;&#039;&#039; — done (was tiny, only pre-Stalwart messages)&lt;br /&gt;
* [x] &#039;&#039;&#039;Verify in SnappyMail&#039;&#039;&#039; — folder hierarchy confirmed, messages accessible&lt;br /&gt;
* [ ] &#039;&#039;&#039;Import old iRedMail mbox&#039;&#039;&#039; — &amp;lt;code&amp;gt;/var/mail/mnw&amp;lt;/code&amp;gt; (37MB) not yet done (low priority, very old mail)&lt;br /&gt;
&lt;br /&gt;
=== Phase 4 (Getmail) — COMPLETE ✓ (one item remaining) ===&lt;br /&gt;
* [x] &#039;&#039;&#039;Update delivery targets&#039;&#039;&#039; in .rc files → Stalwart LMTP &amp;lt;code&amp;gt;localhost:24&amp;lt;/code&amp;gt;&lt;br /&gt;
* [x] &#039;&#039;&#039;Create craniumslows-marcus.rc&#039;&#039;&#039; — fetches marcus@craniumslows.com from MXRoute, 439/439 delivered&lt;br /&gt;
* [x] &#039;&#039;&#039;Keep &amp;lt;code&amp;gt;delete = false&amp;lt;/code&amp;gt;&#039;&#039;&#039; — correctly set; flip to true only after validating in daily use&lt;br /&gt;
* [x] &#039;&#039;&#039;Create systemd timer&#039;&#039;&#039; — getmail runs every 10 minutes via systemd&lt;br /&gt;
* [x] &#039;&#039;&#039;Fastmail import COMPLETE&#039;&#039;&#039; — 12,226/12,226 delivered (2026-02-22 ~15:52)&lt;br /&gt;
* [ ] ~~Configure SnappyMail additional accounts~~ — &#039;&#039;&#039;ARCHITECTURE CHANGED&#039;&#039;&#039; (see below): external accounts now use getmail, not SnappyMail connected accounts&lt;br /&gt;
&lt;br /&gt;
=== Architecture decision (2026-02-22) ===&lt;br /&gt;
Marcus&#039;s external accounts (Gmail x2, iCloud, SDF) use &#039;&#039;&#039;getmail&#039;&#039;&#039; → Stalwart, NOT SnappyMail connected accounts. This gives a true unified inbox with sieve filtering. Phone/clients connect to one IMAP server.&lt;br /&gt;
* [x] &#039;&#039;&#039;Add getmail config: thewilson@gmail.com&#039;&#039;&#039; — &amp;lt;code&amp;gt;sieve/gmail-thewilson.rc&amp;lt;/code&amp;gt; done. Import in progress: ~7,560/58,520 as of 2026-02-23 07:00 (~50 hrs remaining at current rate)&lt;br /&gt;
* [x] &#039;&#039;&#039;Add getmail config: craniumslows@gmail.com&#039;&#039;&#039; — &amp;lt;code&amp;gt;sieve/gmail-craniumslows.rc&amp;lt;/code&amp;gt; done. Will start after thewilson finishes.&lt;br /&gt;
* [x] &#039;&#039;&#039;Add getmail config: marcusthewilson@me.com&#039;&#039;&#039; — &amp;lt;code&amp;gt;sieve/icloud-marcusthewilson.rc&amp;lt;/code&amp;gt; done.&lt;br /&gt;
* [x] &#039;&#039;&#039;Add getmail config: mnw@sdf.org&#039;&#039;&#039; — &amp;lt;code&amp;gt;sieve/sdf-mnw.rc&amp;lt;/code&amp;gt; done. &amp;lt;code&amp;gt;delete=false&amp;lt;/code&amp;gt;.&lt;br /&gt;
* [x] &#039;&#039;&#039;Update systemd getmail.service&#039;&#039;&#039; — all 6 rc files active, daemon reloaded 2026-02-23.&lt;br /&gt;
&lt;br /&gt;
=== Action needed — Phase 5 (Spam + Sieve) ===&lt;br /&gt;
* [x] &#039;&#039;&#039;Fastmail import COMPLETE&#039;&#039;&#039; — 12,226/12,226 (done 2026-02-22)&lt;br /&gt;
* [x] &#039;&#039;&#039;Sieve rules written&#039;&#039;&#039; — &amp;lt;code&amp;gt;sieve/marcus.sieve&amp;lt;/code&amp;gt; ready. Decade-based numbering, all rules merged. See &amp;lt;code&amp;gt;sieve/README.md&amp;lt;/code&amp;gt;.&lt;br /&gt;
* [x] &#039;&#039;&#039;Mail classification script written&#039;&#039;&#039; — &amp;lt;code&amp;gt;sieve/classify-inbox.py&amp;lt;/code&amp;gt; done.&lt;br /&gt;
&#039;&#039; [x] &#039;&#039;&#039;Audit actual Stalwart folder names&#039;&#039;&#039; — confirmed INBOX/XX-&#039;&#039; naming in place.&lt;br /&gt;
* [x] &#039;&#039;&#039;Upload sieve/marcus.sieve&#039;&#039;&#039; — uploaded and ACTIVE via ManageSieve (confirmed 2026-02-23). Script: &amp;lt;code&amp;gt;sieve/upload-sieve.py&amp;lt;/code&amp;gt;.&lt;br /&gt;
* [x] &#039;&#039;&#039;Dedup script written&#039;&#039;&#039; — &amp;lt;code&amp;gt;sieve/dedup.py&amp;lt;/code&amp;gt;. Dry-run confirmed 6,622 dupes. Run &amp;lt;code&amp;gt;--execute&amp;lt;/code&amp;gt; to purge.&lt;br /&gt;
* [ ] &#039;&#039;&#039;Run dedup + retroactive sort + empty .Junk&#039;&#039;&#039; — → &amp;lt;code&amp;gt;plans/sieve-dedup.md&amp;lt;/code&amp;gt; (covers all three)&lt;br /&gt;
* [ ] &#039;&#039;&#039;Enable server-wide spam filter&#039;&#039;&#039; — after sieve rules confirmed working in daily use&lt;br /&gt;
&lt;br /&gt;
=== Action needed — Phase 6 (n8n provisioner + per-user relay) ===&lt;br /&gt;
* [x] &#039;&#039;&#039;Create &amp;lt;code&amp;gt;craniumslows-email&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;wilsoz-email&amp;lt;/code&amp;gt; groups&#039;&#039;&#039; in Authentik — done 2026-02-22; marcus in both&lt;br /&gt;
* [x] &#039;&#039;&#039;Marcus&#039;s &amp;lt;code&amp;gt;mailAddresses&amp;lt;/code&amp;gt; attribute set&#039;&#039;&#039; — &amp;lt;code&amp;gt;{&amp;quot;mailAddresses&amp;quot;: [&amp;quot;marcus@craniumslows.com&amp;quot;]}&amp;lt;/code&amp;gt; — enables dual-domain delivery in Stalwart&lt;br /&gt;
* [x] &#039;&#039;&#039;Build n8n provisioner workflow&#039;&#039;&#039; — Matrix bot IYw0DmCuWEsiV2k9 (deployed 2026-03-01). &amp;lt;code&amp;gt;!provision&amp;lt;/code&amp;gt; in #notifications room, walks through username/name/password/domain conversation. Adaptive polling: 15s during conversation, 5-min throttle when idle.&lt;br /&gt;
* [x] &#039;&#039;&#039;Add custom LDAP attributes&#039;&#039;&#039; to Authentik users: &amp;lt;code&amp;gt;mail-name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;mail-alias&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;default-domain&amp;lt;/code&amp;gt; (future, when adding more users) — &#039;&#039;&#039;done 2026-05-10&#039;&#039;&#039;: attributes backfilled on marcus and now written by the provisioner for new users; LDAP property mappings + downstream consumers still deferred&lt;br /&gt;
* [x] &#039;&#039;&#039;Configure global MXRoute rate limit&#039;&#039;&#039; — Stalwart queue throttle 400 msg/hr to blizzard.mxrouting.net. → &amp;lt;code&amp;gt;plans/stalwart-helpers-fix.md&amp;lt;/code&amp;gt; (Phase 5) — &#039;&#039;&#039;done 2026-05-10&#039;&#039;&#039;: 400/1h limiter set 2026-05-10 via legacy prefix/values API&lt;br /&gt;
* [x] &#039;&#039;&#039;Migrate marcus to per-user relay routes&#039;&#039;&#039; — replace shared craniumslows-mxroute with marcus-specific route — &#039;&#039;&#039;done 2026-05-10&#039;&#039;&#039;: marcus-craniumslows relay + per-sender rule added; shared craniumslows-mxroute kept as fallback&lt;br /&gt;
&lt;br /&gt;
=== Action needed — jkatz@craniumslows.com cleanup ===&lt;br /&gt;
* [x] &#039;&#039;&#039;jkatz Authentik user&#039;&#039;&#039; — Confirmed deleted (pk:18 not found 2026-05-09). MXRoute account was never created. Consider done.&lt;br /&gt;
&lt;br /&gt;
=== Action needed — Matrix deprovisioner (next session) ===&lt;br /&gt;
* [x] &#039;&#039;&#039;Build Matrix deprovisioner&#039;&#039;&#039; — → &amp;lt;code&amp;gt;plans/email-deprovisioner.md&amp;lt;/code&amp;gt; (depends on &amp;lt;code&amp;gt;plans/stalwart-helpers-fix.md&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;plans/email-provisioner.md&amp;lt;/code&amp;gt;) — &#039;&#039;&#039;done 2026-05-10&#039;&#039;&#039;: deployed as jbGL9N1vXT8mB92V, end-to-end tested 2026-05-10&lt;br /&gt;
&lt;br /&gt;
=== Action needed — Provisioner cleanup strategy ===&lt;br /&gt;
* [x] &#039;&#039;&#039;Define orphan .rc file retention policy&#039;&#039;&#039; — &#039;&#039;&#039;done 2026-05-10&#039;&#039;&#039;: policy = move .rc into disabled/ with &amp;lt;code&amp;gt;.deprov-YYYY-MM-DD&amp;lt;/code&amp;gt; suffix on deprovision; weekly cron (&amp;lt;code&amp;gt;/etc/cron.d/getmail-disabled-purge&amp;lt;/code&amp;gt;) purges entries &amp;gt;30 days old. Legacy duplicates + test residue swept.&lt;br /&gt;
&lt;br /&gt;
=== Action needed — Phase 7 (wilsoz.com → MXRoute) ===&lt;br /&gt;
* [ ] Validate craniumslows.com in daily use (give it weeks)&lt;br /&gt;
* [ ] Move wilsoz.com MX DNS → MXRoute&lt;br /&gt;
* [ ] Replace fastmail.rc with wilsoz-marcus.rc (MXRoute fetch)&lt;br /&gt;
* [ ] Update Stalwart wilsoz.com relay → MXRoute smarthost&lt;br /&gt;
* [ ] &#039;&#039;&#039;Flip &amp;lt;code&amp;gt;delete = true&amp;lt;/code&amp;gt;&#039;&#039;&#039; on all getmail configs once cutover verified&lt;br /&gt;
&lt;br /&gt;
=== Minor ===&lt;br /&gt;
* [ ] Investigate unexpected folders in SnappyMail MoveTo dropdown&lt;br /&gt;
&lt;br /&gt;
=== Status (updated 2026-02-22) ===&lt;br /&gt;
&#039;&#039;&#039;Phases 1–4 complete.&#039;&#039;&#039; Stalwart + SnappyMail deployed, LDAP auth working with dual-domain delivery. Maildir import done (137k+ messages). Getmail running: craniumslows 439/439, Fastmail ~5.5k/12.2k in progress. &amp;lt;code&amp;gt;craniumslows-email&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;wilsoz-email&amp;lt;/code&amp;gt; groups created in Authentik. &#039;&#039;&#039;Phase 5 (spam/sieve) and Phase 6 (n8n provisioner) are next.&#039;&#039;&#039; One manual step remaining: add 4 external accounts to SnappyMail UI.&lt;br /&gt;
&lt;br /&gt;
----&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Postmortems/2026-06-28_HA_Blue_Bootswitch&amp;diff=23</id>
		<title>Postmortems/2026-06-28 HA Blue Bootswitch</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Postmortems/2026-06-28_HA_Blue_Bootswitch&amp;diff=23"/>
		<updated>2026-07-05T18:17:37Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from postmortems/2026-06-28-ha-blue-bootswitch.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `postmortems/2026-06-28-ha-blue-bootswitch.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Postmortem — Home Assistant &amp;quot;offline / won&#039;t get an IP&amp;quot; (2026-06-28) =&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
The Home Assistant box (shante, &amp;lt;code&amp;gt;192.168.0.140&amp;lt;/code&amp;gt;) had been powered off and boxed for&lt;br /&gt;
~weeks. On power-up it would not appear on the network — no DHCP lease, unreachable on&lt;br /&gt;
LAN and Tailscale. Root cause was &#039;&#039;&#039;not&#039;&#039;&#039; a network or HAOS-corruption problem: the&lt;br /&gt;
device&#039;s physical &#039;&#039;&#039;boot-mode toggle switch&#039;&#039;&#039; had been bumped out of the eMMC position,&lt;br /&gt;
so the board booted into its &#039;&#039;&#039;Petitboot recovery shell&#039;&#039;&#039; instead of loading Home&lt;br /&gt;
Assistant OS off the eMMC. HAOS never started, so nothing ever came up on the network.&lt;br /&gt;
Flipping the switch back and rebooting restored normal boot.&lt;br /&gt;
&lt;br /&gt;
== Device identification (corrected) ==&lt;br /&gt;
The unit is a &#039;&#039;&#039;Home Assistant Blue = ODROID-N2+&#039;&#039;&#039;, not a Green/Yellow as the docs had&lt;br /&gt;
it labeled. Identifying tells:&lt;br /&gt;
* &#039;&#039;&#039;Petitboot&#039;&#039;&#039; bootloader screen (Green uses Rockchip/U-Boot; Yellow uses Pi CM4 — neither uses Petitboot).&lt;br /&gt;
* A physical &#039;&#039;&#039;boot-select toggle switch&#039;&#039;&#039; on the front (eMMC ↔ SPI) — an ODROID-N2+ hardware feature.&lt;br /&gt;
* Blue translucent case; red (power) + blue (activity) LEDs visible through the sliding door.&lt;br /&gt;
* HDMI + single Ethernet on the back.&lt;br /&gt;
&lt;br /&gt;
Docs updated: &amp;lt;code&amp;gt;INFRASTRUCTURE_INVENTORY.md&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;HOME_ASSISTANT_INTEGRATION.md&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;VLAN-Setup-Guide.md&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Timeline ==&lt;br /&gt;
* Device boxed unpowered for ~weeks (Tailscale &amp;quot;last seen 26d ago&amp;quot; ≈ 2026-06-02).&lt;br /&gt;
* Powered on; never got an IP. LAN ping/UI dead; Tailscale peer &amp;lt;code&amp;gt;offline&amp;lt;/code&amp;gt;.&lt;br /&gt;
* 8-min network poll for &amp;lt;code&amp;gt;192.168.0.140&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;homeassistant.local&amp;lt;/code&amp;gt; → nothing.&lt;br /&gt;
* HDMI attached → &#039;&#039;&#039;Petitboot&#039;&#039;&#039; menu + &amp;quot;Failed to launch Petitboot, dropping to a shell&amp;quot;, &amp;lt;code&amp;gt;#&amp;lt;/code&amp;gt; busybox prompt.&lt;br /&gt;
* Recognized Petitboot ⇒ bootloader couldn&#039;t find a bootable OS ⇒ boot/storage issue, not network.&lt;br /&gt;
* User flipped the front toggle switch and rebooted → got a login prompt; reconnected to LAN.&lt;br /&gt;
* HA Core first-boot was slow (&amp;quot;Loading data&amp;quot;, login &amp;quot;something went wrong&amp;quot; while backend caught up). Settled after a few minutes.&lt;br /&gt;
* Confirmed back: Tailscale peer &amp;lt;code&amp;gt;active&amp;lt;/code&amp;gt;, direct &amp;lt;code&amp;gt;192.168.0.140:36309&amp;lt;/code&amp;gt;, real rx traffic.&lt;br /&gt;
&lt;br /&gt;
== Root cause ==&lt;br /&gt;
Physical boot-mode toggle was in the SPI/recovery position (likely bumped while boxed),&lt;br /&gt;
so the ODROID-N2+ ran Petitboot&#039;s recovery shell instead of chain-loading HAOS from eMMC.&lt;br /&gt;
&lt;br /&gt;
== Why login &amp;quot;spun then failed&amp;quot; right after recovery ==&lt;br /&gt;
Not an auth bug. A box stored unpowered for weeks does a slow first start — recorder/DB&lt;br /&gt;
catch-up, NTP clock resync, integration re-init. The frontend (port 8123) answers before&lt;br /&gt;
Core is ready, so login spins / &amp;quot;something went wrong&amp;quot; until the backend finishes. Fix:&lt;br /&gt;
&#039;&#039;&#039;wait 5–15 min, don&#039;t reboot mid-startup.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
== Diagnostics that worked ==&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Is it on the network at all?&lt;br /&gt;
ping -c2 192.168.0.140&lt;br /&gt;
curl -sS -m8 -o /dev/null -w &amp;quot;%{http_code}\n&amp;quot; http://192.168.0.140:8123/&lt;br /&gt;
&lt;br /&gt;
# Is Tailscale seeing it? (&amp;quot;offline, last seen Nd ago&amp;quot; = box truly down, not just LAN/DHCP)&lt;br /&gt;
tailscale status | grep -i homeassistant&lt;br /&gt;
&lt;br /&gt;
# At the Petitboot # shell, check whether OS storage is even detected:&lt;br /&gt;
cat /proc/partitions&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Prevention / follow-ups ==&lt;br /&gt;
* &#039;&#039;&#039;Leave the front toggle switch in the eMMC (HAOS-boot) position.&#039;&#039;&#039; If the device ever&lt;br /&gt;
  shows the Petitboot screen again, that switch is the first thing to check.&lt;br /&gt;
* If it&#039;s stored again, label/tape the switch position so it isn&#039;t bumped.&lt;br /&gt;
* Consider noting the correct switch orientation physically on the case.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Postmortems/2026-05-24_Ultraplan_Exposure&amp;diff=22</id>
		<title>Postmortems/2026-05-24 Ultraplan Exposure</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Postmortems/2026-05-24_Ultraplan_Exposure&amp;diff=22"/>
		<updated>2026-07-05T18:17:37Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from postmortems/2026-05-24-ultraplan-exposure.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `postmortems/2026-05-24-ultraplan-exposure.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Postmortem &amp;amp; Remediation Playbook: Ultraplan upload exposure (2026-05-24) =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Status:&#039;&#039;&#039; Remediation in progress. This document is BOTH the postmortem&lt;br /&gt;
and the step-by-step playbook for executing the remediation manually.&lt;br /&gt;
&lt;br /&gt;
== Incident summary ==&lt;br /&gt;
&lt;br /&gt;
While working on the tsbuffer project plan in Claude Code, the &amp;lt;code&amp;gt;/ultraplan&amp;lt;/code&amp;gt;&lt;br /&gt;
command was invoked. &amp;lt;code&amp;gt;/ultraplan&amp;lt;/code&amp;gt; uploads the current working directory&lt;br /&gt;
plus &amp;lt;code&amp;gt;CLAUDE.md&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;MEMORY.md&amp;lt;/code&amp;gt; to an Anthropic-hosted &amp;quot;Claude Code on&lt;br /&gt;
the web&amp;quot; session for remote planning by an agent there.&lt;br /&gt;
&lt;br /&gt;
The remote session terminated with &amp;lt;code&amp;gt;error_during_execution&amp;lt;/code&amp;gt; before&lt;br /&gt;
producing any output. The uploaded content remains in Anthropic cloud&lt;br /&gt;
session storage tied to session ID &amp;lt;code&amp;gt;01GUjtsXpaAw1CqHShfePPmq&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;No warning was surfaced about the upload before it occurred.&#039;&#039;&#039; This is&lt;br /&gt;
the root cause of the exposure — there is no broken security control to&lt;br /&gt;
fix; the tool worked exactly as designed, and we did not know what it did.&lt;br /&gt;
&lt;br /&gt;
== What was in the upload ==&lt;br /&gt;
&lt;br /&gt;
=== Live credentials (rotation required) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Where it was&lt;br /&gt;
! Why it&#039;s bad&lt;br /&gt;
|-&lt;br /&gt;
| n8n API JWT&lt;br /&gt;
| &amp;lt;code&amp;gt;CLAUDE.md&amp;lt;/code&amp;gt;, &amp;quot;n8n API&amp;quot; section, pasted in plaintext, no expiry&lt;br /&gt;
| Anyone with this can call any n8n API endpoint as admin&lt;br /&gt;
|-&lt;br /&gt;
| n8n admin UI password (&amp;lt;code&amp;gt;Htdml0fI1aZgtPBVv5oz&amp;lt;/code&amp;gt;)&lt;br /&gt;
| &amp;lt;code&amp;gt;MEMORY.md&amp;lt;/code&amp;gt;, n8n DB Recovery memory entry&lt;br /&gt;
| Direct UI login as &amp;lt;code&amp;gt;admin@wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Topology / metadata exposed (no rotation possible, hygiene only) ===&lt;br /&gt;
&lt;br /&gt;
* Internal domains: &amp;lt;code&amp;gt;agent.wilsoz.com&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;vault.wilsoz.com&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;key.wilsoz.com&amp;lt;/code&amp;gt;,&lt;br /&gt;
  &amp;lt;code&amp;gt;photos.wilsoz.com&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;ntfy.wilsoz.com&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;mymail.wilsoz.com&amp;lt;/code&amp;gt;,&lt;br /&gt;
  &amp;lt;code&amp;gt;next.wilsoz.com&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;wilsoz.cloudflareaccess.com&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Internal IPs: &amp;lt;code&amp;gt;192.168.0.109&amp;lt;/code&amp;gt; (stronghold), &amp;lt;code&amp;gt;192.168.0.144&amp;lt;/code&amp;gt; (ollama),&lt;br /&gt;
  Docker bridges, Tailscale IP &amp;lt;code&amp;gt;100.67.169.50&amp;lt;/code&amp;gt; for the SDF relay VPS.&lt;br /&gt;
* OpenBao endpoint and secret path layout (paths only, no tokens).&lt;br /&gt;
* Vaultwarden item-naming convention (&amp;quot;Stronghold Infrastructure&amp;quot; folder).&lt;br /&gt;
* MXRoute server &amp;lt;code&amp;gt;blizzard.mxrouting.net&amp;lt;/code&amp;gt;, username &amp;lt;code&amp;gt;craniums&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Authentik admin URL + admin usernames &amp;lt;code&amp;gt;akadmin&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;marcus&amp;lt;/code&amp;gt;; Cloudflare&lt;br /&gt;
  Access OIDC client ID.&lt;br /&gt;
* Matrix bot @zordon:wilsoz.com, room ID, internal Synapse endpoint.&lt;br /&gt;
* All n8n workflow JSONs in &amp;lt;code&amp;gt;n8n-workflows/&amp;lt;/code&amp;gt; and every service doc in&lt;br /&gt;
  &amp;lt;code&amp;gt;services/&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Email addresses including &amp;lt;code&amp;gt;marcus@wilsoz.com&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;admin@wilsoz.com&amp;lt;/code&amp;gt;,&lt;br /&gt;
  &amp;lt;code&amp;gt;jkatz@craniumslows.com&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Top-level constraints for this remediation ==&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;The credentials store IS the root of trust.&#039;&#039;&#039; Vaultwarden + OpenBao&lt;br /&gt;
   are how we manage every other secret. Compromising either means&lt;br /&gt;
   compromising everything else.&lt;br /&gt;
# &#039;&#039;&#039;Never paste a secret into chat, ever.&#039;&#039;&#039; Not even temporarily, not&lt;br /&gt;
   even &amp;quot;just to verify.&amp;quot; Once it&#039;s in the conversation, it&#039;s leaked&lt;br /&gt;
   exactly the same way the original JWT was.&lt;br /&gt;
# &#039;&#039;&#039;Verify each new credential works BEFORE deleting the old one.&#039;&#039;&#039; A&lt;br /&gt;
   half-rotated credential where the new one doesn&#039;t work is worse than&lt;br /&gt;
   the original exposure.&lt;br /&gt;
# &#039;&#039;&#039;No secret values in command-line arguments.&#039;&#039;&#039; They show up in&lt;br /&gt;
   &amp;lt;code&amp;gt;/proc/PID/cmdline&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;ps auxw&amp;lt;/code&amp;gt; output. Use stdin, env vars, or&lt;br /&gt;
   token files mode 0600.&lt;br /&gt;
# &#039;&#039;&#039;Two-person rule (where applicable).&#039;&#039;&#039; For high-blast-radius&lt;br /&gt;
   rotations (root tokens, master password) — don&#039;t rotate without&lt;br /&gt;
   confirming the recovery path first.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Remediation steps (manual playbook) ==&lt;br /&gt;
&lt;br /&gt;
Each step has: (a) what to do, (b) how to verify, (c) what to update,&lt;br /&gt;
(d) rollback.&lt;br /&gt;
&lt;br /&gt;
=== Step 1. Rotate n8n API JWT ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(a) Action:&#039;&#039;&#039;&lt;br /&gt;
* Open https://agent.wilsoz.com in a browser; log in as &amp;lt;code&amp;gt;admin@wilsoz.com&amp;lt;/code&amp;gt;.&lt;br /&gt;
  Password: retrieve from Vaultwarden item &amp;quot;n8n UI Login&amp;quot;. (This is the&lt;br /&gt;
  old password — Step 2 will rotate it. The order matters: API key first,&lt;br /&gt;
  because once we change the admin password we want to verify with the&lt;br /&gt;
  API that everything&#039;s healthy.)&lt;br /&gt;
* Settings → n8n API → see the existing key (do NOT copy its value).&lt;br /&gt;
* Click &amp;quot;Revoke&amp;quot; on the existing key. Confirm.&lt;br /&gt;
* Click &amp;quot;Create an API key&amp;quot;. Set &amp;quot;Time to live&amp;quot; to &amp;quot;No expiration&amp;quot;.&lt;br /&gt;
  Copy the new JWT once shown (the UI shows it exactly once).&lt;br /&gt;
* &#039;&#039;&#039;Paste the new JWT directly into a Vaultwarden item&#039;&#039;&#039; — open&lt;br /&gt;
  Vaultwarden in another tab, edit item &amp;quot;n8n API Key&amp;quot;, paste, save. Do&lt;br /&gt;
  not paste it into a terminal, a text file, or any chat.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(b) Verify:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Retrieve the new key from Vaultwarden via the bw CLI (NOT by pasting):&lt;br /&gt;
NEW_KEY=$(bw get item &amp;quot;n8n API Key&amp;quot; | jq -r &#039;.fields[] | select(.name==&amp;quot;apiKey&amp;quot;) | .value&#039;)&lt;br /&gt;
# (Field name may differ — adjust per your existing Vaultwarden item layout.)&lt;br /&gt;
curl -sf &amp;quot;https://agent.wilsoz.com/api/v1/workflows&amp;quot; \&lt;br /&gt;
  -H &amp;quot;X-N8N-API-KEY: $NEW_KEY&amp;quot; \&lt;br /&gt;
  | jq &#039;.data | length&#039;&lt;br /&gt;
# Expect: a number (count of workflows), no errors.&lt;br /&gt;
unset NEW_KEY&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(c) Update:&#039;&#039;&#039;&lt;br /&gt;
* Vaultwarden item &amp;quot;n8n API Key&amp;quot;: done above.&lt;br /&gt;
* OpenBao at &amp;lt;code&amp;gt;secret/n8n&amp;lt;/code&amp;gt; field &amp;lt;code&amp;gt;api-key&amp;lt;/code&amp;gt;:&lt;br /&gt;
  ```bash&lt;br /&gt;
  # Read the new key from Vaultwarden into a shell var (no echo):&lt;br /&gt;
  NEW_KEY=$(bw get item &amp;quot;n8n API Key&amp;quot; | jq -r &#039;.fields[] | select(.name==&amp;quot;apiKey&amp;quot;) | .value&#039;)&lt;br /&gt;
  # Write to OpenBao using stdin (NOT command-line arg):&lt;br /&gt;
  printf &#039;%s&#039; &amp;quot;$NEW_KEY&amp;quot; | BAO_TOKEN=&amp;quot;$(cat /library/openbao-app/.claude-mcp-token)&amp;quot; \&lt;br /&gt;
    bao kv put -mount=secret n8n api-key=-&lt;br /&gt;
  unset NEW_KEY&lt;br /&gt;
  ```&lt;br /&gt;
* &amp;lt;code&amp;gt;~/Developer/SystemResiliency/CLAUDE.md&amp;lt;/code&amp;gt;: replace the JWT-bearing line&lt;br /&gt;
  with: &amp;lt;code&amp;gt;Retrieve from Vaultwarden item: &amp;quot;n8n API Key&amp;quot;&amp;lt;/code&amp;gt;. Remove the&lt;br /&gt;
  &amp;lt;code&amp;gt;N8N_KEY=&#039;...&#039;&amp;lt;/code&amp;gt; example line that contains the actual JWT in the&lt;br /&gt;
  curl examples; replace with a &amp;lt;code&amp;gt;# N8N_KEY=$(bw get item &amp;quot;n8n API Key&amp;quot; | ...)&amp;lt;/code&amp;gt;&lt;br /&gt;
  pattern.&lt;br /&gt;
* Commit CLAUDE.md change. Verify the diff contains NO JWT value before&lt;br /&gt;
  committing (&amp;lt;code&amp;gt;git diff CLAUDE.md&amp;lt;/code&amp;gt; and visually scan).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(d) Rollback:&#039;&#039;&#039;&lt;br /&gt;
If verification fails, the old JWT was already revoked at n8n — there is&lt;br /&gt;
no rollback to the previous value. Instead, immediately mint another new&lt;br /&gt;
JWT from the n8n UI and retry. The window of &amp;quot;no working API key&amp;quot; is&lt;br /&gt;
acceptable because no automation actively uses it during this rotation.&lt;br /&gt;
&lt;br /&gt;
=== Step 2. Rotate n8n admin UI password ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(a) Action:&#039;&#039;&#039;&lt;br /&gt;
* Still logged into n8n as &amp;lt;code&amp;gt;admin@wilsoz.com&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Settings → Personal → Change Password.&lt;br /&gt;
* Open Vaultwarden, edit item &amp;quot;n8n UI Login&amp;quot;, click the password&lt;br /&gt;
  regenerate button (32 chars, full charset). Save.&lt;br /&gt;
* Copy the new password from Vaultwarden.&lt;br /&gt;
* Paste into n8n&#039;s &amp;quot;New Password&amp;quot; field. Save.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(b) Verify:&#039;&#039;&#039;&lt;br /&gt;
* Sign out of n8n.&lt;br /&gt;
* Sign in with email &amp;lt;code&amp;gt;admin@wilsoz.com&amp;lt;/code&amp;gt; and the new password.&lt;br /&gt;
* Expect: dashboard loads, workflows visible.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(c) Update:&#039;&#039;&#039;&lt;br /&gt;
* Vaultwarden item &amp;quot;n8n UI Login&amp;quot;: done above.&lt;br /&gt;
* OpenBao at &amp;lt;code&amp;gt;secret/n8n&amp;lt;/code&amp;gt; field &amp;lt;code&amp;gt;admin-password&amp;lt;/code&amp;gt; (if used by anything):&lt;br /&gt;
  check first whether anything reads it. If yes, update via stdin pattern&lt;br /&gt;
  above. If no, skip.&lt;br /&gt;
* &amp;lt;code&amp;gt;MEMORY.md&amp;lt;/code&amp;gt; (auto-memory): edit &amp;lt;code&amp;gt;/home/mnw/.claude/projects/-home-mnw-Developer-SystemResiliency/memory/MEMORY.md&amp;lt;/code&amp;gt;.&lt;br /&gt;
  Find the line: &amp;lt;code&amp;gt;Created new owner: admin@wilsoz.com / Htdml0fI1aZgtPBVv5oz&amp;lt;/code&amp;gt;&lt;br /&gt;
  Replace with: &amp;lt;code&amp;gt;Created new owner: admin@wilsoz.com / (rotated 2026-05-24; see Vaultwarden &amp;quot;n8n UI Login&amp;quot;)&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(d) Rollback:&#039;&#039;&#039;&lt;br /&gt;
Same situation as Step 1 — old password is already invalidated. If the&lt;br /&gt;
new password doesn&#039;t work, n8n&#039;s password reset relies on SMTP email&lt;br /&gt;
which is functional. Worst case: re-init the n8n DB (same recovery we&lt;br /&gt;
did 2026-03-11).&lt;br /&gt;
&lt;br /&gt;
=== Step 3. Audit CLAUDE.md and MEMORY.md for OTHER secrets ===&lt;br /&gt;
&lt;br /&gt;
This is a one-pass sweep to catch anything I missed when triaging.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(a) Action:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd ~/Developer/SystemResiliency&lt;br /&gt;
# Install gitleaks if not present:&lt;br /&gt;
which gitleaks || sudo apt install -y gitleaks&lt;br /&gt;
# Scan the project + memory dirs:&lt;br /&gt;
gitleaks detect --source . --no-git -v&lt;br /&gt;
gitleaks detect --source ~/.claude/projects/-home-mnw-Developer-SystemResiliency/memory --no-git -v&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(b) Verify:&#039;&#039;&#039;&lt;br /&gt;
* Read the findings line by line.&lt;br /&gt;
* For each finding: is it actually a secret, or a false positive (e.g.&lt;br /&gt;
  an ID, a hash of a non-sensitive value)?&lt;br /&gt;
* True positives: add to the rotation list, restart from Step 1 for each.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(c) Update:&#039;&#039;&#039;&lt;br /&gt;
* Document any new findings in this file (append a &amp;quot;Found in audit&amp;quot;&lt;br /&gt;
  subsection below).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(d) Rollback:&#039;&#039;&#039;&lt;br /&gt;
N/A — read-only operation.&lt;br /&gt;
&lt;br /&gt;
=== Step 4. Attempt deletion of the cloud session ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(a) Action:&#039;&#039;&#039;&lt;br /&gt;
* Open https://claude.ai/code/session_01GUjtsXpaAw1CqHShfePPmq in a&lt;br /&gt;
  browser. Log in as &amp;lt;code&amp;gt;marcus@wilsoz.com&amp;lt;/code&amp;gt;.&lt;br /&gt;
* Look for a delete / remove option in the session menu (three-dot icon,&lt;br /&gt;
  settings gear, or similar).&lt;br /&gt;
* If found, click it. Confirm.&lt;br /&gt;
* If not found: send an email to &amp;lt;code&amp;gt;privacy@anthropic.com&amp;lt;/code&amp;gt; with the session&lt;br /&gt;
  ID and a request for deletion under data-deletion rights. Reference&lt;br /&gt;
  https://privacy.anthropic.com. Subject:&lt;br /&gt;
  &amp;lt;code&amp;gt;Data deletion request — session 01GUjtsXpaAw1CqHShfePPmq&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(b) Verify:&#039;&#039;&#039;&lt;br /&gt;
* If UI delete worked: refresh the session URL, expect 404 or&lt;br /&gt;
  &amp;quot;session not found&amp;quot;.&lt;br /&gt;
* If email request: save the sent message; expect a reply within 30 days&lt;br /&gt;
  per their stated SLA.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(c) Update:&#039;&#039;&#039;&lt;br /&gt;
* Append the outcome to this file: &amp;quot;Cloud session deletion: [UI delete |&lt;br /&gt;
  email requested on YYYY-MM-DD | response received on YYYY-MM-DD]&amp;quot;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(d) Rollback:&#039;&#039;&#039;&lt;br /&gt;
N/A. Best-effort operation either way.&lt;br /&gt;
&lt;br /&gt;
=== Step 5. Install a pre-commit secret-scanner ===&lt;br /&gt;
&lt;br /&gt;
So a future paste-in is caught before it reaches a commit.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(a) Action:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd ~/Developer/SystemResiliency&lt;br /&gt;
# Add a .gitleaks.toml if you want custom rules; defaults are fine.&lt;br /&gt;
# Install the pre-commit framework if not already:&lt;br /&gt;
which pre-commit || sudo apt install -y pre-commit&lt;br /&gt;
cat &amp;gt; .pre-commit-config.yaml &amp;lt;&amp;lt;&#039;EOF&#039;&lt;br /&gt;
repos:&lt;br /&gt;
  - repo: https://github.com/gitleaks/gitleaks&lt;br /&gt;
    rev: v8.18.0&lt;br /&gt;
    hooks:&lt;br /&gt;
      - id: gitleaks&lt;br /&gt;
EOF&lt;br /&gt;
pre-commit install&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Do the same in &amp;lt;code&amp;gt;~/Developer/jellyfin-adhoc/&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;~/Developer/tsbuffer/&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(b) Verify:&#039;&#039;&#039;&lt;br /&gt;
* Make a trivial change to a file in the repo.&lt;br /&gt;
* Stage it. Try to commit.&lt;br /&gt;
* Expect: pre-commit runs, gitleaks scans the staged diff, commit&lt;br /&gt;
  proceeds (because the diff is clean).&lt;br /&gt;
* Manually test the catch: stage a file containing &amp;lt;code&amp;gt;EXAMPLE_KEY=eyJabc.def.ghi&amp;lt;/code&amp;gt;,&lt;br /&gt;
  commit; expect gitleaks to block.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(c) Update:&#039;&#039;&#039;&lt;br /&gt;
* Commit the &amp;lt;code&amp;gt;.pre-commit-config.yaml&amp;lt;/code&amp;gt; to each repo.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;(d) Rollback:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;code&amp;gt;pre-commit uninstall&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
=== Step 6. Adopt a policy on cloud-handoff tools ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;/ultraplan&amp;lt;/code&amp;gt; is convenient but its blast radius is the entire working&lt;br /&gt;
directory. Several reasonable options:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Block it entirely&#039;&#039;&#039; via a Claude Code hook in &amp;lt;code&amp;gt;~/.claude/settings.json&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Allow but require confirmation&#039;&#039;&#039; via a pre-tool hook that shows the&lt;br /&gt;
  user the upload manifest before proceeding.&lt;br /&gt;
* &#039;&#039;&#039;Sanitize first&#039;&#039;&#039; — never invoke from a directory that contains&lt;br /&gt;
  CLAUDE.md / MEMORY.md.&lt;br /&gt;
&lt;br /&gt;
Decision pending — capture in &amp;lt;code&amp;gt;~/Developer/SystemResiliency/TODO.md&amp;lt;/code&amp;gt; for&lt;br /&gt;
follow-up after this incident closes.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Outcome (filled in as work happens) ==&lt;br /&gt;
&lt;br /&gt;
* [ ] Step 1 — n8n API JWT rotated&lt;br /&gt;
* [ ] Step 2 — n8n admin password rotated&lt;br /&gt;
* [ ] Step 3 — Audit complete; findings:&lt;br /&gt;
* [ ] Step 4 — Cloud session deletion attempt:&lt;br /&gt;
* [ ] Step 5 — Pre-commit hook installed&lt;br /&gt;
* [ ] Step 6 — Cloud-handoff policy decision:&lt;br /&gt;
&lt;br /&gt;
== Follow-ups (for TODO.md) ==&lt;br /&gt;
&lt;br /&gt;
* Cloud-handoff tool policy decision (Step 6 above).&lt;br /&gt;
* After this exposure, audit existing OpenBao &amp;lt;code&amp;gt;scripts-n8n&amp;lt;/code&amp;gt; and&lt;br /&gt;
  &amp;lt;code&amp;gt;claude-mcp&amp;lt;/code&amp;gt; tokens: are they scoped tightly enough? Should they be&lt;br /&gt;
  rotated on a schedule (e.g. every 90 days) regardless of leaks?&lt;br /&gt;
* Consider whether Vaultwarden master password should be rotated on a&lt;br /&gt;
  cadence (high blast radius if leaked; can&#039;t be auto-rotated).&lt;br /&gt;
&lt;br /&gt;
== Lessons ==&lt;br /&gt;
&lt;br /&gt;
# Any Claude Code tool that &amp;quot;uploads context&amp;quot; needs an explicit&lt;br /&gt;
   user-confirmation prompt with the manifest of what will be sent. The&lt;br /&gt;
   silent default is a footgun.&lt;br /&gt;
# CLAUDE.md and MEMORY.md are read by everything: every Claude Code&lt;br /&gt;
   session, plus any tool that exports project context. They must never&lt;br /&gt;
   contain literal secrets. The bar should be &amp;quot;would I publish this to&lt;br /&gt;
   a public README?&amp;quot; — if no, it doesn&#039;t belong in those files.&lt;br /&gt;
# Rotation tedium is the actual problem behind &amp;quot;I&#039;ll just inline this&lt;br /&gt;
   credential for now.&amp;quot; The exposure-remediation script (see&lt;br /&gt;
   &amp;lt;code&amp;gt;scripts/exposure-remediation/PLAN.md&amp;lt;/code&amp;gt;) is the load-bearing follow-up.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Postmortems/2026-05-07_n8n_HA_Credential_Repair&amp;diff=21</id>
		<title>Postmortems/2026-05-07 n8n HA Credential Repair</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Postmortems/2026-05-07_n8n_HA_Credential_Repair&amp;diff=21"/>
		<updated>2026-07-05T18:17:36Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from postmortems/2026-05-07-n8n-ha-credential-repair.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `postmortems/2026-05-07-n8n-ha-credential-repair.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Postmortem: n8n Credential Failures &amp;amp; HA Workflow Repair =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Date:&#039;&#039;&#039; 2026-05-07  &lt;br /&gt;
&#039;&#039;&#039;Duration:&#039;&#039;&#039; ~6 hours of cascading failures (backup alerts since 2026-03-11 DB recovery)  &lt;br /&gt;
&#039;&#039;&#039;Severity:&#039;&#039;&#039; Medium — silent failures, no data loss, but monitoring blind spots  &lt;br /&gt;
&#039;&#039;&#039;Status:&#039;&#039;&#039; Resolved&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
&lt;br /&gt;
After the March 2026 n8n SQLite DB recovery, 12 workflows were silently failing due to stale/placeholder credential IDs. Home Assistant workflows had additional issues: wrong credential format, missing entity IDs, and parallel-merge node connections that n8n can&#039;t execute correctly.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Root Causes (in order discovered) ==&lt;br /&gt;
&lt;br /&gt;
=== 1. Stale SSH credential IDs from DB recovery ===&lt;br /&gt;
The March 2026 DB recovery recreated credentials with new IDs, but workflow JSONs stored on disk still had old IDs. When workflows were re-imported they referenced credentials that no longer existed.&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;CREDENTIAL_ID&amp;lt;/code&amp;gt; — literal placeholder, never substituted  &lt;br /&gt;
* &amp;lt;code&amp;gt;S2muq5nzJFa5ZydC&amp;lt;/code&amp;gt; — old SSH credential from pre-recovery DB&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current correct SSH credential ID:&#039;&#039;&#039; &amp;lt;code&amp;gt;2ugS1ibsZu1LQid8&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Missing &amp;lt;code&amp;gt;homeAssistantApi&amp;lt;/code&amp;gt; credential ===&lt;br /&gt;
The &amp;lt;code&amp;gt;homeAssistantApi&amp;lt;/code&amp;gt;-type credential (different from &amp;lt;code&amp;gt;httpHeaderAuth&amp;lt;/code&amp;gt;) was lost in the DB recovery. Eight workflows referenced ID &amp;lt;code&amp;gt;6CIwjyqyUwnvaezn&amp;lt;/code&amp;gt; which no longer existed.&lt;br /&gt;
&lt;br /&gt;
=== 3. Wrong credential &amp;lt;code&amp;gt;host&amp;lt;/code&amp;gt; field format ===&lt;br /&gt;
n8n&#039;s &amp;lt;code&amp;gt;homeAssistantApi&amp;lt;/code&amp;gt; credential uses &#039;&#039;&#039;separate &amp;lt;code&amp;gt;host&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;port&amp;lt;/code&amp;gt; fields&#039;&#039;&#039; — the host is just the IP/hostname with no scheme, no port:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;json&amp;quot;&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;host&amp;quot;: &amp;quot;192.168.0.140&amp;quot;,&lt;br /&gt;
  &amp;quot;port&amp;quot;: 8123,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;: &amp;quot;...&amp;quot;,&lt;br /&gt;
  &amp;quot;ssl&amp;quot;: false&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Wrong formats tried (and why they fail):&#039;&#039;&#039;&lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;quot;host&amp;quot;: &amp;quot;http://homeassistant.local:8123&amp;quot;&amp;lt;/code&amp;gt; → Docker can&#039;t resolve &amp;lt;code&amp;gt;.local&amp;lt;/code&amp;gt; mDNS names  &lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;quot;host&amp;quot;: &amp;quot;http://192.168.0.140:8123&amp;quot;&amp;lt;/code&amp;gt; → node builds URL as &amp;lt;code&amp;gt;http://http://192.168.0.140:8123&amp;lt;/code&amp;gt; → DNS lookup of literal string &amp;lt;code&amp;gt;&amp;quot;http&amp;quot;&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;EAI_AGAIN&amp;lt;/code&amp;gt;  &lt;br /&gt;
* &amp;lt;code&amp;gt;&amp;quot;host&amp;quot;: &amp;quot;192.168.0.140:8123&amp;quot;&amp;lt;/code&amp;gt; → node builds URL as &amp;lt;code&amp;gt;http://192.168.0.140:8123:8123&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;ERR_INVALID_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How discovered:&#039;&#039;&#039; Read the actual compiled source at:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
docker exec n8n find /usr/local/lib/node_modules/n8n -path &amp;quot;*HomeAssistant/GenericFunctions.js&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
URL construction: `&amp;lt;code&amp;gt; &amp;lt;/code&amp;gt;${ssl?&#039;https&#039;:&#039;http&#039;}://${credentials.host}:${credentials.port}/api${resource}&amp;lt;code&amp;gt; &amp;lt;/code&amp;gt;`&lt;br /&gt;
&lt;br /&gt;
=== 4. Missing entity IDs in HA node parameters ===&lt;br /&gt;
The &amp;lt;code&amp;gt;entityId&amp;lt;/code&amp;gt; field was never saved in the original workflow JSONs exported from n8n. All HA state-read nodes had &amp;lt;code&amp;gt;resource: &amp;quot;state&amp;quot;&amp;lt;/code&amp;gt; but no &amp;lt;code&amp;gt;entityId&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Correct entity IDs used:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Node purpose&lt;br /&gt;
! Entity ID&lt;br /&gt;
|-&lt;br /&gt;
| Living room temp&lt;br /&gt;
| &amp;lt;code&amp;gt;sensor.ewelink_snzb_02p_temperature&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Beta sensor temp&lt;br /&gt;
| &amp;lt;code&amp;gt;sensor.temp_sensor_beta_temperature&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Closet/Charlie temp&lt;br /&gt;
| &amp;lt;code&amp;gt;sensor.temp_sensor_charlie_temperature&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Closet/Charlie humidity&lt;br /&gt;
| &amp;lt;code&amp;gt;sensor.temp_sensor_charlie_humidity&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Door sensor&lt;br /&gt;
| &amp;lt;code&amp;gt;binary_sensor.shellydw2_075374_door&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Weather forecast&lt;br /&gt;
| &amp;lt;code&amp;gt;weather.forecast_ronwood&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Phone next alarm&lt;br /&gt;
| &amp;lt;code&amp;gt;sensor.pixel_7_next_alarm&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Announce/TTS&lt;br /&gt;
| &amp;lt;code&amp;gt;media_player.home_assistant_voice_098a64&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| HA notify&lt;br /&gt;
| &amp;lt;code&amp;gt;notify.home_assistant&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== 5. Parallel-merge node connections ===&lt;br /&gt;
Workflows had multiple HA nodes connected in &#039;&#039;&#039;parallel&#039;&#039;&#039; (all from the trigger), all feeding into one downstream code node. n8n runs the downstream node as soon as the first upstream node finishes — so the code crashes with &amp;lt;code&amp;gt;&amp;quot;Node X hasn&#039;t been executed&amp;quot;&amp;lt;/code&amp;gt; when it tries to read data from the still-running nodes.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Pattern causing the bug:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
Trigger ──┬── Get Temp A ──┐&lt;br /&gt;
          ├── Get Temp B ──┼── Analyze  ← runs on first finish, others not done&lt;br /&gt;
          └── Get Temp C ──┘&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix — chain them sequentially:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
Trigger → Get Temp A → Get Temp B → Get Temp C → Analyze&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Each node passes its result downstream; the code node then accesses all previous nodes via &amp;lt;code&amp;gt;$(&#039;Get Temp A&#039;).first()&amp;lt;/code&amp;gt; etc.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Affected workflows:&#039;&#039;&#039;&lt;br /&gt;
* Server Closet Monitor (Temp + Humidity)&lt;br /&gt;
* Home Temperature Alert (3 temp sensors)&lt;br /&gt;
* Morning Briefing (Weather + Energy Usage)&lt;br /&gt;
* Bedtime Routine (Door check + Weather)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Diagnostic Approach ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How to find execution errors when the n8n API returns no detail:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# execution_data table has the full run data&lt;br /&gt;
sudo sqlite3 /var/lib/docker/volumes/n8n-app_n8n_data/_data/database.sqlite \&lt;br /&gt;
  &amp;quot;SELECT substr(ed.data,1,5000) FROM execution_entity ee &lt;br /&gt;
   JOIN execution_data ed ON ee.id = ed.executionId &lt;br /&gt;
   WHERE ee.id = &amp;lt;EXEC_ID&amp;gt;;&amp;quot;&lt;br /&gt;
# Then grep for: EAI_AGAIN, ENOTFOUND, ERR_INVALID_URL, &amp;quot;message&amp;quot;, getaddrinfo&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;How to find which credentials are broken:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Valid credential IDs in DB&lt;br /&gt;
sudo sqlite3 /var/lib/docker/volumes/n8n-app_n8n_data/_data/database.sqlite \&lt;br /&gt;
  &amp;quot;SELECT id, name, type FROM credentials_entity ORDER BY name;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Scan all workflows for IDs not in that list&lt;br /&gt;
# (see the audit script logic used in this session)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;After any credential fix — n8n must be restarted:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo -E sh -c &#039;cd /library/n8n-app &amp;amp;&amp;amp; docker compose restart n8n&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
n8n caches credential validation state. Even after fixing a credential in the DB, workflows show &amp;quot;has issues&amp;quot; until restart.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Current Credential IDs (verified 2026-05-07) ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! ID&lt;br /&gt;
! Name&lt;br /&gt;
! Type&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;2ugS1ibsZu1LQid8&amp;lt;/code&amp;gt;&lt;br /&gt;
| Stronghold SSH (mnw)&lt;br /&gt;
| sshPrivateKey&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;4RowxM0jrmYDnctK&amp;lt;/code&amp;gt;&lt;br /&gt;
| Home Assistant API&lt;br /&gt;
| homeAssistantApi&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;w0gXpM8W793fahAY&amp;lt;/code&amp;gt;&lt;br /&gt;
| ntfy Auth&lt;br /&gt;
| httpHeaderAuth&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;x5tv5aXs5N5nd2aK&amp;lt;/code&amp;gt;&lt;br /&gt;
| Matrix Bot (Zordon)&lt;br /&gt;
| httpHeaderAuth&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;R9LGxXIhmWCRM5Cn&amp;lt;/code&amp;gt;&lt;br /&gt;
| Home Assistant API (old httpHeaderAuth)&lt;br /&gt;
| httpHeaderAuth&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;5MCznsMsxjqOrlhJ&amp;lt;/code&amp;gt;&lt;br /&gt;
| AudioBookshelf API&lt;br /&gt;
| httpHeaderAuth&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;XB0V2UwPdWkRJoWj&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenAI Bearer&lt;br /&gt;
| httpHeaderAuth&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;NIQrbnzjhpNzBV1P&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenAi account&lt;br /&gt;
| openAiApi&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;HA credential data format (correct):&#039;&#039;&#039;&lt;br /&gt;
* host: &amp;lt;code&amp;gt;192.168.0.140&amp;lt;/code&amp;gt; (IP only, no scheme, no port)&lt;br /&gt;
* port: &amp;lt;code&amp;gt;8123&amp;lt;/code&amp;gt;&lt;br /&gt;
* ssl: &amp;lt;code&amp;gt;false&amp;lt;/code&amp;gt;&lt;br /&gt;
* accessToken: from Vaultwarden → &amp;quot;Home Assistant Long-Lived Token&amp;quot;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Prevention ==&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;After any n8n DB recovery:&#039;&#039;&#039; run the credential audit script before assuming workflows work. Check every active workflow&#039;s credential IDs against &amp;lt;code&amp;gt;credentials_entity&amp;lt;/code&amp;gt;.&lt;br /&gt;
# &#039;&#039;&#039;Export workflow JSONs immediately after any credential re-creation&#039;&#039;&#039; so the repo stays in sync.&lt;br /&gt;
# &#039;&#039;&#039;Never use &amp;lt;code&amp;gt;homeassistant.local&amp;lt;/code&amp;gt; in n8n&#039;&#039;&#039; — Docker containers can&#039;t resolve mDNS. Always use &amp;lt;code&amp;gt;192.168.0.140&amp;lt;/code&amp;gt;.&lt;br /&gt;
# &#039;&#039;&#039;n8n parallel-merge pattern is broken&#039;&#039;&#039; — if multiple nodes need to read data before a code node, chain them sequentially, not in parallel.&lt;br /&gt;
# &#039;&#039;&#039;Test workflows after changes&#039;&#039;&#039; — check n8n execution history within 30 min of any credential/workflow change.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Postmortems/2026-05-05_Jellyfin_XE_GPU_Hang&amp;diff=20</id>
		<title>Postmortems/2026-05-05 Jellyfin XE GPU Hang</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Postmortems/2026-05-05_Jellyfin_XE_GPU_Hang&amp;diff=20"/>
		<updated>2026-07-05T18:17:36Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from postmortems/2026-05-05-jellyfin-xe-gpu-hang.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `postmortems/2026-05-05-jellyfin-xe-gpu-hang.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Postmortem: Jellyfin AV1 Transcoding Causes System Hard Hang =&lt;br /&gt;
&#039;&#039;&#039;Date:&#039;&#039;&#039; 2026-05-05  &lt;br /&gt;
&#039;&#039;&#039;Duration:&#039;&#039;&#039; ~2 minutes (20:28 → hard reset → 20:31 back online)  &lt;br /&gt;
&#039;&#039;&#039;Severity:&#039;&#039;&#039; High — full system halt, required physical hard reset  &lt;br /&gt;
&#039;&#039;&#039;Status:&#039;&#039;&#039; Mitigated (HW transcoding disabled)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
&lt;br /&gt;
Pressing play on a video in Jellyfin triggered hardware-accelerated AV1 encoding via Intel QuickSync (QSV) on the Intel Arc B580 (Battlemage). The xe kernel driver entered a GPU execution queue timeout loop (&amp;lt;code&amp;gt;guc_exec_queue_timedout_job&amp;lt;/code&amp;gt;) on both GT0 and GT1, could not recover, and the system hard-locked. Required a physical power reset to recover.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Timeline ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Time (CST)&lt;br /&gt;
! Event&lt;br /&gt;
|-&lt;br /&gt;
| 20:28:53&lt;br /&gt;
| Jellyfin starts ffmpeg transcoding &amp;lt;code&amp;gt;Omega Doom (1996).mp4&amp;lt;/code&amp;gt; using &amp;lt;code&amp;gt;av1_qsv&amp;lt;/code&amp;gt; encoder via VAAPI/QSV&lt;br /&gt;
|-&lt;br /&gt;
| 20:30:09&lt;br /&gt;
| Kernel floods journal with &amp;lt;code&amp;gt;sysrq: HELP&amp;lt;/code&amp;gt; messages — system in distress&lt;br /&gt;
|-&lt;br /&gt;
| 20:30:40&lt;br /&gt;
| xe driver reports &amp;lt;code&amp;gt;guc_exec_queue_timedout_job&amp;lt;/code&amp;gt; resets on GT0 and GT1 repeatedly (dozens of attempts, all failing)&lt;br /&gt;
|-&lt;br /&gt;
| ~20:30:45&lt;br /&gt;
| System hard-locks. No response to input.&lt;br /&gt;
|-&lt;br /&gt;
| ~20:31:28&lt;br /&gt;
| Hard reset performed. System boots. All services recover normally.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Root Cause ==&lt;br /&gt;
&lt;br /&gt;
Jellyfin was configured to use Intel QuickSync (QSV) hardware acceleration. When transcoding the H.264 source file, ffmpeg selected &amp;lt;code&amp;gt;av1_qsv&amp;lt;/code&amp;gt; as the output encoder. AV1 encoding on the Arc B580 via the &amp;lt;code&amp;gt;xe&amp;lt;/code&amp;gt; kernel driver is unstable — the GPU execution queues time out, the driver attempts repeated resets, fails to recover, and the kernel hangs.&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;xe&amp;lt;/code&amp;gt; driver (Intel&#039;s replacement for &amp;lt;code&amp;gt;i915&amp;lt;/code&amp;gt; for Arc/Battlemage GPUs) is still maturing. AV1 hardware encoding is the newest and least-tested codec path on this driver. The system has no watchdog capable of recovering from this class of GPU hang.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;ffmpeg command at crash:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
/usr/lib/jellyfin-ffmpeg/ffmpeg&lt;br /&gt;
  -init_hw_device vaapi=va:/dev/dri/renderD128,driver=iHD&lt;br /&gt;
  -init_hw_device qsv=qs@va&lt;br /&gt;
  -hwaccel vaapi -hwaccel_output_format vaapi&lt;br /&gt;
  -codec:v:0 av1_qsv&lt;br /&gt;
  ...&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Kernel error (repeated ~50+ times):&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
xe 0000:0b:00.0: [drm] Tile0: GT0: trying reset from guc_exec_queue_timedout_job [xe]&lt;br /&gt;
xe 0000:0b:00.0: [drm] Tile0: GT1: trying reset from guc_exec_queue_timedout_job [xe]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== What Was NOT the Cause ==&lt;br /&gt;
&lt;br /&gt;
* This is unrelated to the previous &amp;lt;code&amp;gt;enable_dc=0&amp;lt;/code&amp;gt; fix (that addressed display C-state timeouts on monitor switch, a separate xe instability)&lt;br /&gt;
* Not an OOM event — 62GB RAM, only 13GB used&lt;br /&gt;
* Not a filesystem or RAID issue&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Fix Applied ==&lt;br /&gt;
&lt;br /&gt;
Disabled hardware transcoding in Jellyfin:&lt;br /&gt;
* Dashboard → Admin → Playback → Transcoding → Hardware acceleration → &#039;&#039;&#039;None&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
ffmpeg now uses software (CPU) encoding. The Ryzen 5 3600X (12 threads) is capable of handling typical transcoding load in software.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Diagnostic Commands ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check previous boot&#039;s GPU errors&lt;br /&gt;
journalctl -b -1 | grep &amp;quot;xe\|guc\|drm&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Check current boot for xe issues&lt;br /&gt;
dmesg -T | grep -i &amp;quot;xe\|guc\|drm\|hang\|reset&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# See what Jellyfin&#039;s ffmpeg was doing at crash time&lt;br /&gt;
journalctl -u jellyfin --since &amp;quot;2026-05-05 20:25&amp;quot; --until &amp;quot;2026-05-05 20:32&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Hardware Context ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Component&lt;br /&gt;
! Detail&lt;br /&gt;
|-&lt;br /&gt;
| GPU&lt;br /&gt;
| Intel Arc B580 (Battlemage), PCI 0b:00.0, device e20b&lt;br /&gt;
|-&lt;br /&gt;
| Kernel driver&lt;br /&gt;
| xe (replacing i915 for Battlemage)&lt;br /&gt;
|-&lt;br /&gt;
| Kernel&lt;br /&gt;
| 6.18.1-061801-generic&lt;br /&gt;
|-&lt;br /&gt;
| CPU&lt;br /&gt;
| AMD Ryzen 5 3600X (6c/12t)&lt;br /&gt;
|-&lt;br /&gt;
| RAM&lt;br /&gt;
| 62GB&lt;br /&gt;
|-&lt;br /&gt;
| Existing xe fix&lt;br /&gt;
| &amp;lt;code&amp;gt;options xe enable_dc=0&amp;lt;/code&amp;gt; in modprobe.d (display C-state fix, unrelated)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Options for Restoring Hardware Transcoding ==&lt;br /&gt;
&lt;br /&gt;
=== Option A: Replace GPU with NVIDIA RTX 3060/4060 ===&lt;br /&gt;
* NVENC/NVDEC drivers are rock-solid on Linux&lt;br /&gt;
* RTX 3060: AV1 decode only (no AV1 encode)&lt;br /&gt;
* RTX 4060: AV1 encode + decode, ~$300&lt;br /&gt;
* Drop-in PCIe x16, works with AM4/X570 platform&lt;br /&gt;
* No driver instability issues for media workloads&lt;br /&gt;
&lt;br /&gt;
=== Option B: Replace GPU with Intel Arc A770 (Alchemist gen) ===&lt;br /&gt;
* Uses &amp;lt;code&amp;gt;i915&amp;lt;/code&amp;gt; driver (mature, not &amp;lt;code&amp;gt;xe&amp;lt;/code&amp;gt;)&lt;br /&gt;
* Full AV1 encode + decode&lt;br /&gt;
* ~$200–250&lt;br /&gt;
* Lower risk than Battlemage on xe&lt;br /&gt;
&lt;br /&gt;
=== Option C: Wait for xe driver to mature / upgrade kernel ===&lt;br /&gt;
* xe driver improves rapidly; a future kernel may handle this gracefully&lt;br /&gt;
* No cost, but timeline is unknown&lt;br /&gt;
* Risky — hangs are full system halts, not graceful crashes&lt;br /&gt;
&lt;br /&gt;
=== Option D: Keep B580, disable AV1 encode in Jellyfin only ===&lt;br /&gt;
* Keep VAAPI HW accel but force output codec to H.264 or H.265&lt;br /&gt;
* H.264/H.265 VAAPI paths on xe are more stable (but not guaranteed crash-free)&lt;br /&gt;
* Still some risk of xe driver hang on other codec paths&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Recommendation:&#039;&#039;&#039; Option A (RTX 4060) if budget allows — eliminates the entire class of xe driver instability and gives better AV1 support. Option B (Arc A770) is the budget pick if you want to stay Intel.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Follow-up Actions ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action&lt;br /&gt;
! Priority&lt;br /&gt;
! Status&lt;br /&gt;
|-&lt;br /&gt;
| Disable HW transcoding in Jellyfin&lt;br /&gt;
| Critical&lt;br /&gt;
| Done&lt;br /&gt;
|-&lt;br /&gt;
| Decide on GPU replacement&lt;br /&gt;
| Medium&lt;br /&gt;
| Pending&lt;br /&gt;
|-&lt;br /&gt;
| Add stronghold hardware specs to INFRASTRUCTURE_INVENTORY.md&lt;br /&gt;
| Low&lt;br /&gt;
| Pending&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Postmortems/2026-04-15_Immich_Crash&amp;diff=19</id>
		<title>Postmortems/2026-04-15 Immich Crash</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Postmortems/2026-04-15_Immich_Crash&amp;diff=19"/>
		<updated>2026-07-05T18:17:36Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from postmortems/2026-04-15-immich-crash.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `postmortems/2026-04-15-immich-crash.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Postmortem: Immich Crash &amp;amp; Service Degradation =&lt;br /&gt;
&#039;&#039;&#039;Date:&#039;&#039;&#039; 2026-04-15  &lt;br /&gt;
&#039;&#039;&#039;Duration:&#039;&#039;&#039; Unknown start → resolved ~22:30 CST  &lt;br /&gt;
&#039;&#039;&#039;Severity:&#039;&#039;&#039; High — Immich completely unavailable, photos inaccessible  &lt;br /&gt;
&#039;&#039;&#039;Status:&#039;&#039;&#039; Resolved&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Summary ==&lt;br /&gt;
&lt;br /&gt;
Immich became unavailable due to a Docker bridge networking bug that left the server container unable to reach Redis. When the server was restarted to fix this, a second problem emerged: 11,744 assets in the database had missing source files on disk, causing the thumbnail generation job queue to storm-crash the process with exit code 139 (SIGSEGV) on every boot. The server then entered a persistent crash-restart loop. Root causes were identified, the bad assets were cleaned up in the database, and the server was restored to full health.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Timeline ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Time (CST)&lt;br /&gt;
! Event&lt;br /&gt;
|-&lt;br /&gt;
| Unknown&lt;br /&gt;
| Docker bridge veth bug silently breaks immich_server → immich_redis routing&lt;br /&gt;
|-&lt;br /&gt;
| Unknown&lt;br /&gt;
| Server begins crash-restart loop (Redis unreachable, EHOSTUNREACH)&lt;br /&gt;
|-&lt;br /&gt;
| ~21:00&lt;br /&gt;
| User reports Immich down; authenticated successfully but immediately logged out&lt;br /&gt;
|-&lt;br /&gt;
| ~21:05&lt;br /&gt;
| Investigation: &amp;lt;code&amp;gt;docker ps&amp;lt;/code&amp;gt; shows immich_server &amp;quot;Up 46 seconds&amp;quot; — crash-looping&lt;br /&gt;
|-&lt;br /&gt;
| ~21:10&lt;br /&gt;
| Root cause 1 identified: &amp;lt;code&amp;gt;EHOSTUNREACH 172.18.0.3:6379&amp;lt;/code&amp;gt; — server can&#039;t reach Redis despite same bridge network&lt;br /&gt;
|-&lt;br /&gt;
| ~21:10&lt;br /&gt;
| Root cause 2 identified: &amp;lt;code&amp;gt;immich_postgres&amp;lt;/code&amp;gt; health check failing — stale checksum failure counter from April 4&lt;br /&gt;
|-&lt;br /&gt;
| ~21:10&lt;br /&gt;
| Fix 1: &amp;lt;code&amp;gt;pg_stat_reset()&amp;lt;/code&amp;gt; clears stale checksum counter → postgres becomes healthy&lt;br /&gt;
|-&lt;br /&gt;
| ~21:15&lt;br /&gt;
| Fix 2: &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt; on immich stack — recreates bridge network, resolves veth bug&lt;br /&gt;
|-&lt;br /&gt;
| ~21:20&lt;br /&gt;
| Server comes up healthy. User attempts thumbnail regeneration from admin UI&lt;br /&gt;
|-&lt;br /&gt;
| ~21:22&lt;br /&gt;
| Server crashes immediately with exit code 139 (SIGSEGV)&lt;br /&gt;
|-&lt;br /&gt;
| ~21:25&lt;br /&gt;
| Root cause 3 identified: thumbnail job queue flooded with 11,744+ jobs for missing files; sharp native library segfaults under parallel failure storm&lt;br /&gt;
|-&lt;br /&gt;
| ~21:30&lt;br /&gt;
| Redis thumbnail queue cleared; server stabilises&lt;br /&gt;
|-&lt;br /&gt;
| ~21:35&lt;br /&gt;
| Analysis: 34,397 library assets in DB; only 22,634 files on disk — 11,744 missing&lt;br /&gt;
|-&lt;br /&gt;
| ~21:40&lt;br /&gt;
| sharp tested against all 22,634 on-disk image files — only 4 failures (XMP sidecar files, not real images)&lt;br /&gt;
|-&lt;br /&gt;
| ~21:45&lt;br /&gt;
| Fix 3: 11,744 missing assets marked &amp;lt;code&amp;gt;status=trashed&amp;lt;/code&amp;gt; in DB; 4 XMP entries deleted&lt;br /&gt;
|-&lt;br /&gt;
| ~22:00&lt;br /&gt;
| Server confirmed stable, all assets healthy, thumbnail generation safe to re-run&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Root Causes ==&lt;br /&gt;
&lt;br /&gt;
=== 1. Docker Bridge veth Bug ===&lt;br /&gt;
&#039;&#039;&#039;What happened:&#039;&#039;&#039; After a prior crash-restart cycle, the immich_server container&#039;s virtual Ethernet interface lost routing to other containers on the same Docker bridge network. From the server&#039;s perspective, Redis at &amp;lt;code&amp;gt;172.18.0.3&amp;lt;/code&amp;gt; was unreachable (&amp;lt;code&amp;gt;EHOSTUNREACH&amp;lt;/code&amp;gt;), despite Redis being healthy and all containers nominally on the same &amp;lt;code&amp;gt;immich_default&amp;lt;/code&amp;gt; bridge.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why it happened:&#039;&#039;&#039; This is a known Docker bug where the kernel&#039;s bridge forwarding state for a container&#039;s veth pair becomes stale after a crash-restart cycle. &amp;lt;code&amp;gt;docker ps&amp;lt;/code&amp;gt; shows the container as &amp;quot;Up&amp;quot; but inter-container routing is broken. The bug does not self-heal.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Detection:&#039;&#039;&#039; &amp;lt;code&amp;gt;docker logs immich_server&amp;lt;/code&amp;gt; showed continuous &amp;lt;code&amp;gt;EHOSTUNREACH 172.18.0.3:6379&amp;lt;/code&amp;gt;. Host could ping 172.18.0.3 fine; the server container could not.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039; &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt; — tearing down and recreating the network is the only fix. &amp;lt;code&amp;gt;docker compose restart&amp;lt;/code&amp;gt; does &#039;&#039;&#039;not&#039;&#039;&#039; work as it doesn&#039;t recreate the bridge.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
=== 2. Stale Postgres Checksum Failure Counter ===&lt;br /&gt;
&#039;&#039;&#039;What happened:&#039;&#039;&#039; &amp;lt;code&amp;gt;immich_postgres&amp;lt;/code&amp;gt; was marked &amp;lt;code&amp;gt;(unhealthy)&amp;lt;/code&amp;gt; by Docker, which prevented &amp;lt;code&amp;gt;immich_server&amp;lt;/code&amp;gt; from starting (it has &amp;lt;code&amp;gt;depends_on: database: condition: service_healthy&amp;lt;/code&amp;gt;).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why it happened:&#039;&#039;&#039; A single page checksum failure occurred on April 4 (11 days prior). The failure count accumulates in &amp;lt;code&amp;gt;pg_stat_database&amp;lt;/code&amp;gt; and is never reset automatically. The health check queries &amp;lt;code&amp;gt;SUM(checksum_failures)&amp;lt;/code&amp;gt; and exits non-zero if &amp;gt; 0, so the container stayed unhealthy indefinitely even though the database was functioning normally and no data corruption was present.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Detection:&#039;&#039;&#039; &amp;lt;code&amp;gt;docker inspect immich_postgres --format &#039;{{json .State.Health}}&#039;&amp;lt;/code&amp;gt; showed &amp;lt;code&amp;gt;checksum failure count is 1&amp;lt;/code&amp;gt; with last failure &amp;lt;code&amp;gt;2026-04-04&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039; &amp;lt;code&amp;gt;docker exec immich_postgres psql -U postgres -d immich -c &amp;quot;SELECT pg_stat_reset();&amp;quot;&amp;lt;/code&amp;gt; resets all per-database statistics including the checksum counter. The health check immediately passed on the next interval.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Note:&#039;&#039;&#039; A real checksum failure is worth investigating — it can indicate a dying disk. In this case the single failure was old and isolated; the database was consistent and Immich was functioning (HTTP 200) right up until the networking broke. Monitor for recurrence.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
=== 3. Missing Asset Crash Loop (Primary Crash Cause) ===&lt;br /&gt;
&#039;&#039;&#039;What happened:&#039;&#039;&#039; When thumbnail generation was triggered, the BullMQ job queue was flooded with ~11,744 jobs for assets whose source files no longer exist on disk. The Node.js worker processes fired these jobs in high concurrency. The &amp;lt;code&amp;gt;sharp&amp;lt;/code&amp;gt; native image library (C++ via N-API) segfaulted under the load of simultaneous &amp;quot;Input file is missing&amp;quot; failure paths, killing the entire Node.js process with exit code 139 (SIGSEGV). Docker&#039;s &amp;lt;code&amp;gt;restart: always&amp;lt;/code&amp;gt; policy restarted the server, which immediately picked up the queued jobs and crashed again — a persistent crash loop.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why the files were missing:&#039;&#039;&#039; 34,397 assets were stored in the &amp;quot;uploaded library&amp;quot; path (&amp;lt;code&amp;gt;/usr/src/app/upload/library/&amp;lt;/code&amp;gt;). Only 22,634 files exist on disk. The gap of 11,744 likely accumulated over time as photos were moved, deleted from the host filesystem, or sourced from a device (e.g., iPhone backup migration) that was later reorganised without removing the Immich DB records.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Detection:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
docker logs immich_server → exit code 139 (SIGSEGV)&lt;br /&gt;
find /raid1/.../library -type f | wc -l → 22,634&lt;br /&gt;
SELECT COUNT(*) FROM asset WHERE originalPath LIKE &#039;/usr/src/app/upload/library/%&#039; → 34,397&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix (immediate):&#039;&#039;&#039; Clear the Redis thumbnail queue to stop the crash loop:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec immich_redis redis-cli KEYS &amp;quot;immich_bull:thumbnailGeneration:*&amp;quot; | \&lt;br /&gt;
  xargs docker exec immich_redis redis-cli DEL&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix (permanent):&#039;&#039;&#039; Mark missing assets as trashed so Immich never queues thumbnail generation for them again:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Pipe missing asset IDs into postgres to bulk-trash&lt;br /&gt;
cat /tmp/immich-missing-ids.txt | docker exec -i immich_postgres psql -U postgres -d immich -c &amp;quot;&lt;br /&gt;
CREATE TEMP TABLE missing_ids (id uuid);&lt;br /&gt;
COPY missing_ids FROM STDIN;&lt;br /&gt;
UPDATE asset SET \&amp;quot;deletedAt\&amp;quot; = NOW(), status = &#039;trashed&#039;&lt;br /&gt;
  WHERE id IN (SELECT id FROM missing_ids);&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Additionally, 4 XMP sidecar files (&amp;lt;code&amp;gt;.jpg.xmp&amp;lt;/code&amp;gt;) had been incorrectly indexed as image assets and deleted from the DB:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec immich_postgres psql -U postgres -d immich -c \&lt;br /&gt;
  &amp;quot;DELETE FROM asset WHERE \&amp;quot;originalPath\&amp;quot; LIKE &#039;%.xmp&#039;;&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== What Was NOT Broken ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;HEIC files from Mac auto-upload&#039;&#039;&#039; — All passed sharp&#039;s &amp;lt;code&amp;gt;metadata()&amp;lt;/code&amp;gt; test. Pillow (used for the corruption scan) doesn&#039;t support HEIC natively; those &amp;quot;failures&amp;quot; were false positives.&lt;br /&gt;
* &#039;&#039;&#039;External library photos&#039;&#039;&#039; (70,840 assets in &amp;lt;code&amp;gt;/app/local-images/&amp;lt;/code&amp;gt;) — Library scan confirmed 0 offlined, all intact.&lt;br /&gt;
* &#039;&#039;&#039;Thumbnails on disk&#039;&#039;&#039; — 190,882 thumbnail files were present and correctly structured. No regeneration was needed for most assets.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Impact ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Area&lt;br /&gt;
! Impact&lt;br /&gt;
|-&lt;br /&gt;
| Photo browsing&lt;br /&gt;
| Complete outage — all images showed &amp;quot;Error loading image&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| New uploads&lt;br /&gt;
| Unavailable during crash loop&lt;br /&gt;
|-&lt;br /&gt;
| External library&lt;br /&gt;
| Intact, no data loss&lt;br /&gt;
|-&lt;br /&gt;
| Uploaded library&lt;br /&gt;
| 11,744 assets with no source files trashed (files were already gone before today)&lt;br /&gt;
|-&lt;br /&gt;
| Other services&lt;br /&gt;
| None — isolated to Immich stack&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Diagnostic Commands Reference ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check all Immich container statuses&lt;br /&gt;
docker ps --filter &amp;quot;name=immich&amp;quot; --format &amp;quot;table {{.Names}}\t{{.Status}}&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Check crash exit code&lt;br /&gt;
docker inspect immich_server --format &#039;{{.State.ExitCode}}&#039;&lt;br /&gt;
# 139 = SIGSEGV (native crash), 1 = normal error, 0 = clean exit&lt;br /&gt;
&lt;br /&gt;
# Check postgres health detail&lt;br /&gt;
docker inspect immich_postgres --format &#039;{{json .State.Health}}&#039; | python3 -m json.tool | tail -20&lt;br /&gt;
&lt;br /&gt;
# Clear thumbnail queue (safe to run anytime — jobs will re-queue from admin UI on demand)&lt;br /&gt;
docker exec immich_redis redis-cli KEYS &amp;quot;immich_bull:thumbnailGeneration:*&amp;quot; | \&lt;br /&gt;
  xargs docker exec immich_redis redis-cli DEL&lt;br /&gt;
&lt;br /&gt;
# Count assets by storage location&lt;br /&gt;
docker exec immich_postgres psql -U postgres -d immich -t -c &amp;quot;&lt;br /&gt;
SELECT&lt;br /&gt;
  SUM(CASE WHEN \&amp;quot;originalPath\&amp;quot; LIKE &#039;/usr/src/app/upload/library/%&#039; THEN 1 ELSE 0 END) as uploaded,&lt;br /&gt;
  SUM(CASE WHEN \&amp;quot;originalPath\&amp;quot; LIKE &#039;/app/local-images/%&#039; THEN 1 ELSE 0 END) as external,&lt;br /&gt;
  COUNT(*) FILTER (WHERE status = &#039;trashed&#039;) as trashed&lt;br /&gt;
FROM asset;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Find assets with missing files (run on host, translate paths)&lt;br /&gt;
# See: /home/mnw/Developer/SystemResiliency/scripts/immich-find-broken.py&lt;br /&gt;
&lt;br /&gt;
# Reset stale postgres checksum counter&lt;br /&gt;
docker exec immich_postgres psql -U postgres -d immich -c &amp;quot;SELECT pg_stat_reset();&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Follow-up Actions ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Action&lt;br /&gt;
! Priority&lt;br /&gt;
! Status&lt;br /&gt;
|-&lt;br /&gt;
| Run thumbnail generation from Immich admin → Jobs&lt;br /&gt;
| High&lt;br /&gt;
| Pending&lt;br /&gt;
|-&lt;br /&gt;
| Empty Immich trash (11,744 trashed assets)&lt;br /&gt;
| Medium&lt;br /&gt;
| Pending&lt;br /&gt;
|-&lt;br /&gt;
| Investigate why 11,744 uploaded assets lost their source files&lt;br /&gt;
| Low&lt;br /&gt;
| Pending&lt;br /&gt;
|-&lt;br /&gt;
| Monitor postgres checksum failures — recurrence may indicate disk issue&lt;br /&gt;
| Medium&lt;br /&gt;
| Ongoing&lt;br /&gt;
|-&lt;br /&gt;
| Monthly rolling restarts workflow (n8n &amp;lt;code&amp;gt;OlzWTPDsCoO9oEo7&amp;lt;/code&amp;gt;) now active&lt;br /&gt;
| Done&lt;br /&gt;
| Complete&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Prevention ==&lt;br /&gt;
&lt;br /&gt;
The &#039;&#039;&#039;monthly rolling restart workflow&#039;&#039;&#039; deployed today (n8n ID: &amp;lt;code&amp;gt;OlzWTPDsCoO9oEo7&amp;lt;/code&amp;gt;) performs a full &amp;lt;code&amp;gt;compose down &amp;amp;&amp;amp; up -d&amp;lt;/code&amp;gt; on all service stacks on the 1st of each month at 2 AM CST. This proactively recreates Docker bridge networks before the veth bug can silently accumulate, and sends per-service status via Matrix (Zordon). The postgres checksum issue and missing asset problem are not recurring risks given the fixes applied.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/Mealie&amp;diff=18</id>
		<title>Services/Mealie</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/Mealie&amp;diff=18"/>
		<updated>2026-07-05T18:16:42Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Genericize example password placeholder&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/MEALIE.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Mealie — Recipe Manager =&lt;br /&gt;
&lt;br /&gt;
Complete setup &amp;amp; operations guide for Mealie (recipe management app) with Authentik OAuth integration.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Mealie&#039;&#039;&#039; is a self-hosted recipe management app. We run it on Stronghold with:&lt;br /&gt;
* Authentik OAuth2 for user authentication&lt;br /&gt;
* Group-based access control (admin vs user roles)&lt;br /&gt;
* HTTPS via Cloudflare (cook.wilsoz.com)&lt;br /&gt;
* Persistent data storage in Docker volume&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Users:&#039;&#039;&#039; marcus (admin via Authentik)  &lt;br /&gt;
&#039;&#039;&#039;Access:&#039;&#039;&#039; https://cook.wilsoz.com&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Service Information ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Property&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;URL&#039;&#039;&#039;&lt;br /&gt;
| https://cook.wilsoz.com&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Container name&#039;&#039;&#039;&lt;br /&gt;
| mealie&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Compose file&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/mealie-app/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Data volume&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;mealie-app_mealie-data:/app/data&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Internal port&#039;&#039;&#039;&lt;br /&gt;
| 9000&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Host port&#039;&#039;&#039;&lt;br /&gt;
| 9925&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Network&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;mealie-app_mealie&amp;lt;/code&amp;gt; (MTU 1280)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Image&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;ghcr.io/mealie-recipes/mealie:latest&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Initial Setup from Scratch ==&lt;br /&gt;
&lt;br /&gt;
=== 1. Create Directory &amp;amp; Files ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p /library/mealie-app&lt;br /&gt;
cd /library/mealie-app&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Create docker-compose.yml ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;yaml&amp;quot;&amp;gt;&lt;br /&gt;
version: &amp;quot;3.8&amp;quot;&lt;br /&gt;
services:&lt;br /&gt;
  mealie:&lt;br /&gt;
    image: ghcr.io/mealie-recipes/mealie:latest&lt;br /&gt;
    container_name: mealie&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
    volumes:&lt;br /&gt;
      - mealie-data:/app/data&lt;br /&gt;
    ports:&lt;br /&gt;
      - &amp;quot;9925:9000&amp;quot;&lt;br /&gt;
    networks:&lt;br /&gt;
      - mealie&lt;br /&gt;
&lt;br /&gt;
volumes:&lt;br /&gt;
  mealie-data:&lt;br /&gt;
&lt;br /&gt;
networks:&lt;br /&gt;
  mealie:&lt;br /&gt;
    driver: bridge&lt;br /&gt;
    driver_opts:&lt;br /&gt;
      com.docker.network.driver.mtu: 1280&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. Create .env File ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# App Configuration&lt;br /&gt;
PUID=1000&lt;br /&gt;
PGID=1000&lt;br /&gt;
TZ=America/Chicago&lt;br /&gt;
BASE_URL=https://cook.wilsoz.com&lt;br /&gt;
ALLOW_SIGNUP=false&lt;br /&gt;
&lt;br /&gt;
# OIDC Authentication (Authentik)&lt;br /&gt;
OIDC_AUTH_ENABLED=true&lt;br /&gt;
OIDC_PROVIDER_NAME=Authentik&lt;br /&gt;
OIDC_CONFIGURATION_URL=https://key.wilsoz.com/application/o/mealie/.well-known/openid-configuration&lt;br /&gt;
OIDC_CLIENT_ID=3fUfT5FALoAqvWHzqiD0eLNYKd3fsyYjdUkEUVy5&lt;br /&gt;
OIDC_CLIENT_SECRET=D5IbtoxMgx6Kkmry296bXUz6G3NcvzT70wIBrr7RFu8&lt;br /&gt;
OIDC_SIGNUP_ENABLED=true&lt;br /&gt;
OIDC_AUTO_REDIRECT=false&lt;br /&gt;
OIDC_USER_GROUP=family-and-friends&lt;br /&gt;
OIDC_ADMIN_GROUP=authentik Admins&lt;br /&gt;
OIDC_GROUPS_CLAIM=groups&lt;br /&gt;
OIDC_USER_CLAIM=email&lt;br /&gt;
OIDC_NAME_CLAIM=name&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4. Set Up Authentik OAuth Provider ===&lt;br /&gt;
&lt;br /&gt;
==== Create OAuth2 Provider in Authentik ====&lt;br /&gt;
&lt;br /&gt;
# Go to https://key.wilsoz.com/if/admin/&lt;br /&gt;
# &#039;&#039;&#039;Applications → Providers → Create OAuth2/OpenID Provider&#039;&#039;&#039;&lt;br /&gt;
# Fill in:&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Name:** mealie-oauth&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Authorization flow:** (default-authentication-flow)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Client type:** Confidential&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Redirect URIs:** (one per line)&lt;br /&gt;
     ```&lt;br /&gt;
     https://cook.wilsoz.com/login&lt;br /&gt;
     https://cook.wilsoz.com/login?direct=1&lt;br /&gt;
     ```&lt;br /&gt;
# &#039;&#039;&#039;Save&#039;&#039;&#039; — note the &#039;&#039;&#039;Client ID&#039;&#039;&#039; and &#039;&#039;&#039;Client Secret&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==== Configure Provider: Signing Key ====&lt;br /&gt;
&lt;br /&gt;
⚠️ &#039;&#039;&#039;CRITICAL:&#039;&#039;&#039; Assign the signing key or JWKS endpoint will return &amp;lt;code&amp;gt;{}&amp;lt;/code&amp;gt; and OAuth will fail.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
from authentik.crypto.models import CertificateKeyPair&lt;br /&gt;
&lt;br /&gt;
p = OAuth2Provider.objects.get(name=&#039;mealie-oauth&#039;)&lt;br /&gt;
jwt_key = CertificateKeyPair.objects.get(name=&#039;authentik Internal JWT Certificate&#039;)&lt;br /&gt;
p.signing_key = jwt_key&lt;br /&gt;
p.save()&lt;br /&gt;
print(&#039;Assigned JWT Certificate signing key&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why JWT Certificate?&#039;&#039;&#039; &lt;br /&gt;
* Uses HS256 algorithm (Mealie&#039;s OIDC library supports it)&lt;br /&gt;
* Self-signed Certificate uses RS256 (rejected by Mealie)&lt;br /&gt;
&lt;br /&gt;
==== Configure Provider: Property Mappings ====&lt;br /&gt;
&lt;br /&gt;
Add OAuth scope property mappings:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
from authentik.core.models import PropertyMapping&lt;br /&gt;
&lt;br /&gt;
p = OAuth2Provider.objects.get(name=&#039;mealie-oauth&#039;)&lt;br /&gt;
email_pm = PropertyMapping.objects.get(name=&#039;authentik default OAuth Mapping: OpenID \\&#039;email\\&#039;&#039;)&lt;br /&gt;
profile_pm = PropertyMapping.objects.get(name=&#039;authentik default OAuth Mapping: OpenID \\&#039;profile\\&#039;&#039;)&lt;br /&gt;
openid_pm = PropertyMapping.objects.get(name=&#039;authentik default OAuth Mapping: OpenID \\&#039;openid\\&#039;&#039;)&lt;br /&gt;
&lt;br /&gt;
p.property_mappings.add(email_pm, profile_pm, openid_pm)&lt;br /&gt;
p.include_claims_in_id_token = True&lt;br /&gt;
p.save()&lt;br /&gt;
print(&#039;Added property mappings and enabled claims in ID token&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Create Application in Authentik ====&lt;br /&gt;
&lt;br /&gt;
# Go to &#039;&#039;&#039;Applications → Applications → Create&#039;&#039;&#039;&lt;br /&gt;
# Fill in:&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Name:** Mealie&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Slug:** mealie&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Provider:** mealie-oauth (select from dropdown)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Authorization flow:** default-authorization-flow&lt;br /&gt;
# &#039;&#039;&#039;Save&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
=== 5. Start Mealie ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/mealie-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Wait ~10 seconds for startup, then check:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs mealie --tail=20&lt;br /&gt;
# Should show: &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
# And: &amp;quot;Enabled: True&amp;quot; under the OIDC section&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6. Configure VPS Nginx Proxy ===&lt;br /&gt;
&lt;br /&gt;
On VPS, add server block to nginx config (or update if exists):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;nginx&amp;quot;&amp;gt;&lt;br /&gt;
server {&lt;br /&gt;
    listen 443 ssl http2;&lt;br /&gt;
    server_name cook.wilsoz.com;&lt;br /&gt;
&lt;br /&gt;
    ssl_certificate /etc/letsencrypt/live/cook.wilsoz.com/fullchain.pem;&lt;br /&gt;
    ssl_certificate_key /etc/letsencrypt/live/cook.wilsoz.com/privkey.pem;&lt;br /&gt;
&lt;br /&gt;
    location / {&lt;br /&gt;
        proxy_pass http://100.64.207.155:9925;&lt;br /&gt;
        proxy_set_header Host $host;&lt;br /&gt;
        proxy_set_header X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Then:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo systemctl reload nginx&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 7. Create First Admin User (In-App) ===&lt;br /&gt;
&lt;br /&gt;
# Navigate to https://cook.wilsoz.com&lt;br /&gt;
# You&#039;ll see the login page with &amp;quot;Authentik&amp;quot; button&lt;br /&gt;
# If Mealie is brand new with no users, it may show an initial setup screen&lt;br /&gt;
# Use Authentik to log in with an account in the &#039;&#039;&#039;authentik Admins&#039;&#039;&#039; group&lt;br /&gt;
** This account gets Mealie admin role automatically&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Operations ==&lt;br /&gt;
&lt;br /&gt;
=== Start/Stop/Restart ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/mealie-app&lt;br /&gt;
&lt;br /&gt;
# Start&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Stop&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Restart&lt;br /&gt;
docker compose restart mealie&lt;br /&gt;
&lt;br /&gt;
# Full restart (clears cached config)&lt;br /&gt;
docker compose down &amp;amp;&amp;amp; sleep 2 &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View Logs ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Live logs&lt;br /&gt;
docker logs -f mealie&lt;br /&gt;
&lt;br /&gt;
# Last 50 lines&lt;br /&gt;
docker logs mealie --tail=50&lt;br /&gt;
&lt;br /&gt;
# Filter for errors&lt;br /&gt;
docker logs mealie 2&amp;gt;&amp;amp;1 | grep -i &amp;quot;error\|exception&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Filter for OIDC&lt;br /&gt;
docker logs mealie 2&amp;gt;&amp;amp;1 | grep -i &amp;quot;oidc&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Reset Admin Password (Database) ===&lt;br /&gt;
&lt;br /&gt;
If you lose the admin password:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec mealie python3 -c &amp;quot;&lt;br /&gt;
import sqlite3&lt;br /&gt;
import bcrypt&lt;br /&gt;
&lt;br /&gt;
db = sqlite3.connect(&#039;/app/data/mealie.db&#039;)&lt;br /&gt;
cursor = db.cursor()&lt;br /&gt;
&lt;br /&gt;
# Set new password for marcus&lt;br /&gt;
new_password = &#039;&amp;lt;choose-a-new-password-here&amp;gt;&#039;&lt;br /&gt;
salt = bcrypt.gensalt(rounds=10)&lt;br /&gt;
hashed = bcrypt.hashpw(new_password.encode(&#039;utf-8&#039;), salt).decode(&#039;utf-8&#039;)&lt;br /&gt;
&lt;br /&gt;
cursor.execute(&#039;UPDATE users SET password = ? WHERE username = ?&#039;, (hashed, &#039;marcus&#039;))&lt;br /&gt;
db.commit()&lt;br /&gt;
print(f&#039;Password reset to: {new_password}&#039;)&lt;br /&gt;
db.close()&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Then log in and change it in Mealie settings.&lt;br /&gt;
&lt;br /&gt;
=== Manage Users &amp;amp; Groups ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Via Authentik:&#039;&#039;&#039;&lt;br /&gt;
* Users must be added to Authentik first&lt;br /&gt;
* Create/manage groups: &#039;&#039;&#039;Directory → Groups&#039;&#039;&#039;&lt;br /&gt;
* Add users to groups for role assignment&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Via Mealie:&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Settings → Users&#039;&#039;&#039; — shows Mealie users (created on first OAuth login)&lt;br /&gt;
* User roles are determined by Authentik group membership:&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;authentik Admins** → Mealie admin&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;family-and-friends** → Mealie user&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Configuration Reference ==&lt;br /&gt;
&lt;br /&gt;
=== Environment Variables (&amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Variable&lt;br /&gt;
! Purpose&lt;br /&gt;
! Example&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;BASE_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
| External URL for Mealie&lt;br /&gt;
| &amp;lt;code&amp;gt;https://cook.wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ALLOW_SIGNUP&amp;lt;/code&amp;gt;&lt;br /&gt;
| Allow registration without OIDC&lt;br /&gt;
| &amp;lt;code&amp;gt;false&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_AUTH_ENABLED&amp;lt;/code&amp;gt;&lt;br /&gt;
| Enable OAuth login&lt;br /&gt;
| &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_CLIENT_ID&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik provider client ID&lt;br /&gt;
| (from Authentik)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_CLIENT_SECRET&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik provider secret&lt;br /&gt;
| (from Authentik)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_CONFIGURATION_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
| OIDC discovery endpoint&lt;br /&gt;
| &amp;lt;code&amp;gt;https://key.wilsoz.com/application/o/mealie/.well-known/openid-configuration&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_USER_GROUP&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik group for regular users&lt;br /&gt;
| &amp;lt;code&amp;gt;family-and-friends&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_ADMIN_GROUP&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik group for admins&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik Admins&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_GROUPS_CLAIM&amp;lt;/code&amp;gt;&lt;br /&gt;
| OIDC claim name for groups&lt;br /&gt;
| &amp;lt;code&amp;gt;groups&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_SIGNUP_ENABLED&amp;lt;/code&amp;gt;&lt;br /&gt;
| Auto-create users on first OIDC login&lt;br /&gt;
| &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_AUTO_REDIRECT&amp;lt;/code&amp;gt;&lt;br /&gt;
| Skip login page, go straight to Authentik&lt;br /&gt;
| &amp;lt;code&amp;gt;false&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;TZ&amp;lt;/code&amp;gt;&lt;br /&gt;
| Timezone&lt;br /&gt;
| &amp;lt;code&amp;gt;America/Chicago&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;PUID&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;PGID&amp;lt;/code&amp;gt;&lt;br /&gt;
| Linux user/group for file permissions&lt;br /&gt;
| &amp;lt;code&amp;gt;1000&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Data &amp;amp; Backups ==&lt;br /&gt;
&lt;br /&gt;
=== Data Location ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
/library/mealie-app/mealie-data:/app/data&lt;br /&gt;
&lt;br /&gt;
Contents:&lt;br /&gt;
- mealie.db              (SQLite database — all recipes, users, settings)&lt;br /&gt;
- recipes/               (recipe files)&lt;br /&gt;
- users/                 (user data)&lt;br /&gt;
- backups/               (exported recipe backups)&lt;br /&gt;
- templates/             (custom templates)&lt;br /&gt;
- groups/                (group/household data)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Backup Procedure ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Backup the entire data volume&lt;br /&gt;
docker run --rm \&lt;br /&gt;
  -v mealie-app_mealie-data:/data \&lt;br /&gt;
  -v /home/mnw/backups:/backup \&lt;br /&gt;
  ubuntu tar czf /backup/mealie-$(date +%Y%m%d).tar.gz /data&lt;br /&gt;
&lt;br /&gt;
# List backups&lt;br /&gt;
ls -lh /home/mnw/backups/mealie-*.tar.gz&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Restore from Backup ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Stop Mealie&lt;br /&gt;
cd /library/mealie-app &amp;amp;&amp;amp; docker compose down&lt;br /&gt;
&lt;br /&gt;
# Restore data&lt;br /&gt;
docker run --rm \&lt;br /&gt;
  -v mealie-app_mealie-data:/data \&lt;br /&gt;
  -v /home/mnw/backups:/backup \&lt;br /&gt;
  ubuntu bash -c &amp;quot;rm -rf /data/* &amp;amp;&amp;amp; tar xzf /backup/mealie-20260513.tar.gz -C /data&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Restart&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Login Issues ===&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;Something went wrong&amp;quot; on OAuth callback ====&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039;&lt;br /&gt;
* Authentik login succeeds, but Mealie shows error when redirected back&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Common causes &amp;amp; fixes:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Error&lt;br /&gt;
! Cause&lt;br /&gt;
! Fix&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;KeyError: &#039;keys&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
| JWKS endpoint returns &amp;lt;code&amp;gt;{}&amp;lt;/code&amp;gt;&lt;br /&gt;
| Assign signing key to OAuth provider (see Setup step 4)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;UnsupportedAlgorithmError: RS256 not allowed&amp;lt;/code&amp;gt;&lt;br /&gt;
| Wrong signing key algorithm&lt;br /&gt;
| Use &amp;quot;authentik Internal JWT Certificate&amp;quot; (HS256), not &amp;quot;Self-signed Certificate&amp;quot; (RS256)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;invalid_client: Client authentication failed&amp;lt;/code&amp;gt;&lt;br /&gt;
| Client secret mismatch&lt;br /&gt;
| Verify &amp;lt;code&amp;gt;OIDC_CLIENT_SECRET&amp;lt;/code&amp;gt; in .env matches Authentik provider&lt;br /&gt;
|-&lt;br /&gt;
| HTTP 500 at &amp;lt;code&amp;gt;/api/auth/oauth/callback&amp;lt;/code&amp;gt;&lt;br /&gt;
| Mealie can&#039;t reach Authentik&#039;s token endpoint&lt;br /&gt;
| Check network connectivity, SSL certificates, DNS&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Debug steps:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check OIDC config is loaded&lt;br /&gt;
docker logs mealie | grep -i &amp;quot;oidc\|enabled&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Test discovery endpoint&lt;br /&gt;
docker exec mealie curl -s &amp;quot;https://key.wilsoz.com/application/o/mealie/.well-known/openid-configuration&amp;quot; -k | jq .&lt;br /&gt;
&lt;br /&gt;
# Check JWKS endpoint has keys&lt;br /&gt;
docker exec mealie curl -s &amp;quot;https://key.wilsoz.com/application/o/mealie/jwks/&amp;quot; -k | jq &#039;.keys | length&#039;&lt;br /&gt;
&lt;br /&gt;
# View OAuth errors&lt;br /&gt;
docker logs mealie --tail=100 | grep -i &amp;quot;oauth\|error\|exception&amp;quot; | tail -20&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;OIDC not appearing on login page&amp;quot; ====&lt;br /&gt;
&lt;br /&gt;
* Verify &amp;lt;code&amp;gt;OIDC_AUTH_ENABLED=true&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;&lt;br /&gt;
* Restart container: &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt;&lt;br /&gt;
* Check logs for startup errors&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;Redirect URI mismatch&amp;quot; error ====&lt;br /&gt;
&lt;br /&gt;
* Ensure Authentik provider redirect URIs match exactly:&lt;br /&gt;
** &amp;lt;code&amp;gt;https://cook.wilsoz.com/login&amp;lt;/code&amp;gt;&lt;br /&gt;
** &amp;lt;code&amp;gt;https://cook.wilsoz.com/login?direct=1&amp;lt;/code&amp;gt;&lt;br /&gt;
* Verify &amp;lt;code&amp;gt;BASE_URL=https://cook.wilsoz.com&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;&lt;br /&gt;
* No trailing slashes or port numbers&lt;br /&gt;
&lt;br /&gt;
==== User not getting admin role ====&lt;br /&gt;
&lt;br /&gt;
* Confirm user is in &#039;&#039;&#039;authentik Admins&#039;&#039;&#039; group (Directory → Groups)&lt;br /&gt;
* Log out and log back in (roles are assigned at login time)&lt;br /&gt;
* Check Mealie Settings → Users to see assigned role&lt;br /&gt;
&lt;br /&gt;
=== Performance ===&lt;br /&gt;
&lt;br /&gt;
==== Slow startup ====&lt;br /&gt;
&lt;br /&gt;
Mealie can take 30-60 seconds on first start or after upgrade. Check:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs mealie | tail -20&lt;br /&gt;
# Look for &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== High memory usage ====&lt;br /&gt;
&lt;br /&gt;
Default image is fine for small recipe collections. For very large:&lt;br /&gt;
* Monitor: &amp;lt;code&amp;gt;docker stats mealie&amp;lt;/code&amp;gt;&lt;br /&gt;
* Limit: Add &amp;lt;code&amp;gt;mem_limit: 2g&amp;lt;/code&amp;gt; to compose file if needed&lt;br /&gt;
&lt;br /&gt;
=== Network Issues ===&lt;br /&gt;
&lt;br /&gt;
==== Can&#039;t access https://cook.wilsoz.com ====&lt;br /&gt;
&lt;br /&gt;
# Check VPS nginx config (see Setup step 6)&lt;br /&gt;
# Verify SSL cert is valid: &amp;lt;code&amp;gt;curl -I https://cook.wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
# Check Mealie is running: &amp;lt;code&amp;gt;docker ps | grep mealie&amp;lt;/code&amp;gt;&lt;br /&gt;
# Check port: &amp;lt;code&amp;gt;netstat -tlnp | grep 9925&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Mealie can&#039;t reach Authentik (key.wilsoz.com) ====&lt;br /&gt;
&lt;br /&gt;
From Mealie container:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec mealie curl -v https://key.wilsoz.com/api/v3/ -k&lt;br /&gt;
# Should return 401 (not accessible, but shouldn&#039;t timeout)&lt;br /&gt;
&lt;br /&gt;
docker exec mealie ping -c 1 key.wilsoz.com&lt;br /&gt;
# Should succeed&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Security Notes ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;OIDC_CLIENT_SECRET:&#039;&#039;&#039; Store securely in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;, never commit to git&lt;br /&gt;
* &#039;&#039;&#039;Database:&#039;&#039;&#039; &amp;lt;code&amp;gt;/app/data/mealie.db&amp;lt;/code&amp;gt; contains all user data — protect with filesystem permissions&lt;br /&gt;
* &#039;&#039;&#039;Backups:&#039;&#039;&#039; Backup volume includes full database — encrypt backups if stored externally&lt;br /&gt;
* &#039;&#039;&#039;User signup:&#039;&#039;&#039; &amp;lt;code&amp;gt;ALLOW_SIGNUP=false&amp;lt;/code&amp;gt; means only Authentik users can access (good)&lt;br /&gt;
* &#039;&#039;&#039;Groups:&#039;&#039;&#039; Ensure only trusted users are in &amp;lt;code&amp;gt;authentik Admins&amp;lt;/code&amp;gt; group&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Links &amp;amp; References ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Mealie Docs:&#039;&#039;&#039; https://nightly.mealie.io/&lt;br /&gt;
* &#039;&#039;&#039;Mealie GitHub:&#039;&#039;&#039; https://github.com/mealie-recipes/mealie&lt;br /&gt;
* &#039;&#039;&#039;Authentik OIDC:&#039;&#039;&#039; https://goauthentik.io/docs/providers/oauth2&lt;br /&gt;
* &#039;&#039;&#039;Mealie OIDC Integration Guide:&#039;&#039;&#039; https://integrations.goauthentik.io/documentation/mealie/&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Related Services ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Authentik&#039;&#039;&#039; (auth): &amp;lt;code&amp;gt;/library/authentik-app/&amp;lt;/code&amp;gt; — OAuth provider&lt;br /&gt;
* &#039;&#039;&#039;Nginx&#039;&#039;&#039; (proxy): VPS &amp;lt;code&amp;gt;/etc/nginx/sites-available/&amp;lt;/code&amp;gt; — external access&lt;br /&gt;
* &#039;&#039;&#039;Backups:&#039;&#039;&#039; &amp;lt;code&amp;gt;/home/mnw/backups/&amp;lt;/code&amp;gt; — data snapshots&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/VpsNginx&amp;diff=17</id>
		<title>Services/VpsNginx</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/VpsNginx&amp;diff=17"/>
		<updated>2026-07-05T18:16:22Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/VPS-NGINX.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/VPS-NGINX.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= VPS nginx — vhost management scripts =&lt;br /&gt;
&lt;br /&gt;
Reference for the RackNerd VPS (100.67.169.50, public-facing) that reverse-proxies every &amp;lt;code&amp;gt;*.wilsoz.com&amp;lt;/code&amp;gt; subdomain back to stronghold over Tailscale (100.64.207.155). Read this before hand-writing an nginx vhost — as of 2026-07-05 there are scripts that do the whole thing in one command.&lt;br /&gt;
&lt;br /&gt;
== Scripts (on the VPS, &amp;lt;code&amp;gt;/home/mnw/&amp;lt;/code&amp;gt;) ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Script&lt;br /&gt;
! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;add-vhost.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
| Create a vhost, symlink it, get a cert, add security headers, reload, verify — one command&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;remove-vhost.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
| Tear a vhost down: remove symlink, delete/revoke cert, delete vhost file, reload&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;VHOST-HOWTO.md&amp;lt;/code&amp;gt;&lt;br /&gt;
| Full usage doc, lives alongside the scripts on the VPS&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Add a site ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo /home/mnw/add-vhost.sh &amp;lt;domain&amp;gt; &amp;lt;tailscale-ip:port&amp;gt;&lt;br /&gt;
# Example:&lt;br /&gt;
sudo /home/mnw/add-vhost.sh wiki.wilsoz.com 100.64.207.155:9926&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Does, in order: writes &amp;lt;code&amp;gt;/etc/nginx/sites-available/&amp;lt;domain&amp;gt;&amp;lt;/code&amp;gt; with a plain HTTP proxy block → symlinks into &amp;lt;code&amp;gt;sites-enabled/&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;nginx -t&amp;lt;/code&amp;gt; + reload → &amp;lt;code&amp;gt;certbot --nginx -d &amp;lt;domain&amp;gt;&amp;lt;/code&amp;gt; (Cloudflare DNS-01, rewrites the file with the SSL block + 80→443 redirect, same as every other cert on this VPS) → injects &amp;lt;code&amp;gt;include /etc/nginx/snippets/security-headers.conf;&amp;lt;/code&amp;gt; right after certbot&#039;s &amp;lt;code&amp;gt;options-ssl-nginx.conf&amp;lt;/code&amp;gt; line → final &amp;lt;code&amp;gt;nginx -t&amp;lt;/code&amp;gt; + reload → &amp;lt;code&amp;gt;curl&amp;lt;/code&amp;gt; status check.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Prerequisites before running:&#039;&#039;&#039; DNS A record for the domain already pointing at the VPS (100.67.169.50); the backend already up and reachable on stronghold at the given port. WebSocket backends need &amp;lt;code&amp;gt;proxy_http_version 1.1&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;Upgrade&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Connection&amp;lt;/code&amp;gt; headers added manually afterward — the script doesn&#039;t detect that case.&lt;br /&gt;
&lt;br /&gt;
=== Remove a site ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo /home/mnw/remove-vhost.sh &amp;lt;domain&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Shows you the backend it&#039;s about to remove, asks whether to &#039;&#039;&#039;revoke&#039;&#039;&#039; the cert (&amp;lt;code&amp;gt;y&amp;lt;/code&amp;gt; = revoke + delete, use when the domain is gone for good; default &amp;lt;code&amp;gt;N&amp;lt;/code&amp;gt; = delete locally only, use when just moving/rebuilding), removes the &amp;lt;code&amp;gt;sites-enabled&amp;lt;/code&amp;gt; symlink, deletes the vhost file, tests + reloads nginx.&lt;br /&gt;
&lt;br /&gt;
== Server topology ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Thing&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| Public VPS&lt;br /&gt;
| 100.67.169.50 (RackNerd)&lt;br /&gt;
|-&lt;br /&gt;
| Home server (stronghold)&lt;br /&gt;
| 100.64.207.155 (Tailscale)&lt;br /&gt;
|-&lt;br /&gt;
| Backend ports (examples)&lt;br /&gt;
| 9925 = Mealie, 9926 = MediaWiki&lt;br /&gt;
|-&lt;br /&gt;
| Cert method&lt;br /&gt;
| Let&#039;s Encrypt via certbot + Cloudflare DNS-01 (same plugin as &amp;lt;code&amp;gt;mymail.wilsoz.com&amp;lt;/code&amp;gt;, see CLAUDE.md)&lt;br /&gt;
|-&lt;br /&gt;
| HSTS&lt;br /&gt;
| &amp;lt;code&amp;gt;max-age=300&amp;lt;/code&amp;gt; — staged rollout, expect this to increase over time&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Known nginx warnings (harmless, pre-existing) ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;nginx -t&amp;lt;/code&amp;gt; shows an &amp;lt;code&amp;gt;ssl_protocols&amp;lt;/code&amp;gt; redefinition warning for &amp;lt;code&amp;gt;pages.git.wilsoz.com&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;photos.wilsoz.com&amp;lt;/code&amp;gt;, and will show the same for any vhost combining &amp;lt;code&amp;gt;options-ssl-nginx.conf&amp;lt;/code&amp;gt; (certbot) with &amp;lt;code&amp;gt;security-headers.conf&amp;lt;/code&amp;gt;. Cosmetic only — config still loads and serves correctly.&lt;br /&gt;
&lt;br /&gt;
== Gotcha ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;add-vhost.sh&amp;lt;/code&amp;gt; aborts rather than overwriting if the vhost file already exists — safe to run repeatedly/accidentally, but means you must &amp;lt;code&amp;gt;remove-vhost.sh&amp;lt;/code&amp;gt; first (or hand-edit) to change an existing site&#039;s backend port.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/ArcB580Driver&amp;diff=16</id>
		<title>Services/ArcB580Driver</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/ArcB580Driver&amp;diff=16"/>
		<updated>2026-07-05T18:16:21Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/ARC-B580-DRIVER.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/ARC-B580-DRIVER.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Intel Arc B580 (Battlemage) — xe Driver Notes =&lt;br /&gt;
&lt;br /&gt;
== Problem ==&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;xe&amp;lt;/code&amp;gt; kernel driver for the Arc B580 crashes the system when the monitor is switched&lt;br /&gt;
off/on manually. Root cause: the driver uses aggressive display power-saving states (DC states)&lt;br /&gt;
that fail to wake properly on Battlemage, causing a cascade of GPU timeouts that brings&lt;br /&gt;
the system down.&lt;br /&gt;
&lt;br /&gt;
Symptoms in logs:&lt;br /&gt;
&#039;&#039; &amp;lt;code&amp;gt;xe 0000:0b:00.0: [drm] &#039;&#039;ERROR* Tile0: GT1: Force wake domain X failed to ack wake (-ETIMEDOUT)&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;xe 0000:0b:00.0: [drm] Tile0: GT1: trying reset from guc_exec_queue_timedout_job&amp;lt;/code&amp;gt;&lt;br /&gt;
&#039;&#039; &amp;lt;code&amp;gt;xe 0000:0b:00.0: [drm] &#039;&#039;ERROR* PCODE Mailbox failed: 1 Illegal Command&amp;lt;/code&amp;gt;&lt;br /&gt;
* UBSAN: array-index-out-of-bounds in &amp;lt;code&amp;gt;mtd_intel_dg.c&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Fix Applied (2026-05-03) ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;File 1: &amp;lt;code&amp;gt;/etc/modprobe.d/xe-options.conf&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
options xe enable_dc=0&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Disables aggressive display C-states so the driver doesn&#039;t enter a deep sleep it can&#039;t&lt;br /&gt;
wake from when the monitor is switched off.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;File 2: &amp;lt;code&amp;gt;/etc/modprobe.d/blacklist.conf&amp;lt;/code&amp;gt;&#039;&#039;&#039; (appended)&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
blacklist mtd_intel_dg&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
Blacklists the &amp;lt;code&amp;gt;mtd_intel_dg&amp;lt;/code&amp;gt; module which has a confirmed kernel UBSAN bug on this GPU.&lt;br /&gt;
&lt;br /&gt;
Both changes require initramfs rebuild + reboot to take effect.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== How to Revert (if the fix breaks boot) ==&lt;br /&gt;
&lt;br /&gt;
SSH in from another host, then:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Remove the xe options file&lt;br /&gt;
sudo rm /etc/modprobe.d/xe-options.conf&lt;br /&gt;
&lt;br /&gt;
# 2. Remove the two lines added to blacklist.conf&lt;br /&gt;
#    (the blank line, comment, and blacklist entry at the bottom)&lt;br /&gt;
sudo nano /etc/modprobe.d/blacklist.conf&lt;br /&gt;
# Delete the last 3 lines:&lt;br /&gt;
#   (blank line)&lt;br /&gt;
#   # Intel Arc B580 - mtd_intel_dg has a confirmed kernel UBSAN bug (array out-of-bounds)&lt;br /&gt;
#   blacklist mtd_intel_dg&lt;br /&gt;
&lt;br /&gt;
# 3. Rebuild initramfs&lt;br /&gt;
sudo update-initramfs -u&lt;br /&gt;
&lt;br /&gt;
# 4. Reboot&lt;br /&gt;
sudo reboot&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Or as a one-liner if you want to skip the editor:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo rm /etc/modprobe.d/xe-options.conf &amp;amp;&amp;amp; \&lt;br /&gt;
sudo head -n -3 /etc/modprobe.d/blacklist.conf | sudo tee /etc/modprobe.d/blacklist.conf.tmp &amp;amp;&amp;amp; \&lt;br /&gt;
sudo mv /etc/modprobe.d/blacklist.conf.tmp /etc/modprobe.d/blacklist.conf &amp;amp;&amp;amp; \&lt;br /&gt;
sudo update-initramfs -u &amp;amp;&amp;amp; \&lt;br /&gt;
sudo reboot&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== If you need to go back to the Radeon ==&lt;br /&gt;
&lt;br /&gt;
# Power off, swap GPU physically&lt;br /&gt;
# SSH in (or boot to TTY) and blacklist xe so it doesn&#039;t try to load for a missing device:&lt;br /&gt;
   ```bash&lt;br /&gt;
   echo &amp;quot;blacklist xe&amp;quot; | sudo tee /etc/modprobe.d/blacklist-xe.conf&lt;br /&gt;
   sudo update-initramfs -u&lt;br /&gt;
   sudo reboot&lt;br /&gt;
   ```&lt;br /&gt;
# The &amp;lt;code&amp;gt;radeon&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;amdgpu&amp;lt;/code&amp;gt; driver will load automatically depending on which card.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Kernel / Firmware versions at time of fix ==&lt;br /&gt;
&lt;br /&gt;
* Kernel: &amp;lt;code&amp;gt;6.18.1-061801-generic&amp;lt;/code&amp;gt;&lt;br /&gt;
* GuC firmware: &amp;lt;code&amp;gt;xe/bmg_guc_70.bin&amp;lt;/code&amp;gt; version &amp;lt;code&amp;gt;70.60.0&amp;lt;/code&amp;gt;&lt;br /&gt;
* HuC firmware: &amp;lt;code&amp;gt;xe/bmg_huc.bin&amp;lt;/code&amp;gt; version &amp;lt;code&amp;gt;8.2.10&amp;lt;/code&amp;gt;&lt;br /&gt;
* DMC firmware: &amp;lt;code&amp;gt;i915/bmg_dmc.bin&amp;lt;/code&amp;gt; version &amp;lt;code&amp;gt;2.6&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you upgrade the kernel and the crashes stop, the fix may no longer be needed — test by&lt;br /&gt;
removing &amp;lt;code&amp;gt;xe-options.conf&amp;lt;/code&amp;gt; after a kernel upgrade to see if stability holds without it.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/BackupSystem&amp;diff=15</id>
		<title>Services/BackupSystem</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/BackupSystem&amp;diff=15"/>
		<updated>2026-07-05T18:16:21Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/BACKUP_SYSTEM.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/BACKUP_SYSTEM.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Backup System — Restic + Timeshift =&lt;br /&gt;
&lt;br /&gt;
Complete setup &amp;amp; operations guide for Stronghold&#039;s dual-tier backup system: Timeshift (local snapshots) + Restic (remote encrypted backups).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Criticality:&#039;&#039;&#039; &#039;&#039;&#039;CRITICAL — The foundation.&#039;&#039;&#039; All disaster recovery depends on backups. Without them, data loss is permanent. This system enables recovery of every service on Stronghold.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
Stronghold uses a &#039;&#039;&#039;two-tier backup strategy&#039;&#039;&#039; for redundancy and speed:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Timeshift&#039;&#039;&#039; (local, fast)&lt;br /&gt;
** RSYNC-based filesystem snapshots stored on &amp;lt;code&amp;gt;/raid1/timeshift/&amp;lt;/code&amp;gt;&lt;br /&gt;
** 8 snapshots retained (mix of daily, weekly, monthly)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Purpose:** Fast recovery of individual files or full system (within days)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Speed:** ~5-30 minutes to restore (depends on size)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Limitation:** If &amp;lt;code&amp;gt;/raid1&amp;lt;/code&amp;gt; is damaged, all local snapshots are lost&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Restic&#039;&#039;&#039; (remote, encrypted)&lt;br /&gt;
** Incremental encrypted backups sent to Hetzner Storage Box&lt;br /&gt;
** Daily backups at 03:00 CST, retention: 7 daily + 4 weekly + 6 monthly&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Purpose:** Off-site redundancy; survives hardware failure&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Coverage:** All service data, configs, media, databases&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Speed:** ~30 minutes to backup, 1-2 hours per terabyte for restoration&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Limitation:** Requires Hetzner SSH access and Restic repo password&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Recovery decision tree:&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Drive works, need recent files?&#039;&#039;&#039; → Restore from Timeshift (fast)&lt;br /&gt;
* &#039;&#039;&#039;Drive works, lost some service?&#039;&#039;&#039; → Restore from Timeshift or Restic (both available)&lt;br /&gt;
* &#039;&#039;&#039;Drive failed, building new hardware?&#039;&#039;&#039; → Restore from Restic (only source of truth)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Service Information ==&lt;br /&gt;
&lt;br /&gt;
=== Timeshift ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Property&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Storage location&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/raid1/timeshift/&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Snapshot mode&#039;&#039;&#039;&lt;br /&gt;
| RSYNC (not btrfs)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Device&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/dev/md1&amp;lt;/code&amp;gt; (RAID-1 array)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Current snapshots&#039;&#039;&#039;&lt;br /&gt;
| 8 (mix of daily D, weekly W, monthly M, backfill B)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Retention policy&#039;&#039;&#039;&lt;br /&gt;
| Auto: Last 5 daily, 1 weekly, 1 monthly; manual cleanup for older&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Snapshot schedule&#039;&#039;&#039;&lt;br /&gt;
| Daily at 00:00 (D), weekly at 10:00 (W), manual on demand&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Excluded from snapshots&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/docker/volumes&amp;lt;/code&amp;gt; ⚠️, &amp;lt;code&amp;gt;/proc&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/sys&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/tmp&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/var/tmp&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Restic ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Property&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Container name&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;restic-backup&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Image&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;restic/restic:latest&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Compose file&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/home/mnw/Developer/SystemResiliency/backup-stack/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Network mode&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;host&amp;lt;/code&amp;gt; (direct network access)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Backup schedule&#039;&#039;&#039;&lt;br /&gt;
| 03:00 CST daily (systemd timer &amp;lt;code&amp;gt;backup.timer&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Hetzner server&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;u540853.your-storagebox.de:23&amp;lt;/code&amp;gt; (SSH port 23, not 22)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Repo path&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;sftp:u540853@u540853.your-storagebox.de:./backups&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Encryption&#039;&#039;&#039;&lt;br /&gt;
| AES-256, password-based (stored in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; as &amp;lt;code&amp;gt;HETZNER_PASSWORD&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Deduplication&#039;&#039;&#039;&lt;br /&gt;
| Enabled (saves 20-30% space)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Compression&#039;&#039;&#039;&lt;br /&gt;
| zstd (default, good balance)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Retention&#039;&#039;&#039;&lt;br /&gt;
| 7 daily, 4 weekly, 6 monthly snapshots&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Database dumps&#039;&#039;&#039;&lt;br /&gt;
| PostgreSQL dumps staged to &amp;lt;code&amp;gt;/library/backup-stack/dumps/&amp;lt;/code&amp;gt; before backup&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;System-state dump&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/etc&amp;lt;/code&amp;gt; subset + user crontabs + user/system systemd units + dpkg selections tar&#039;d to &amp;lt;code&amp;gt;/library/backup-stack/dumps/system-state-YYYYMMDD.tar.gz&amp;lt;/code&amp;gt; before backup (covers schedulers/configs that live outside &amp;lt;code&amp;gt;/data/*&amp;lt;/code&amp;gt; and would otherwise be lost if &amp;lt;code&amp;gt;/etc&amp;lt;/code&amp;gt; or a user home got wiped)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Initial Setup from Scratch ==&lt;br /&gt;
&lt;br /&gt;
=== Timeshift (Local Snapshots) ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Timeshift is likely already installed and running on your system.&#039;&#039;&#039; To verify or install:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check if Timeshift is installed&lt;br /&gt;
which timeshift&lt;br /&gt;
&lt;br /&gt;
# If not, install (Ubuntu/Debian)&lt;br /&gt;
sudo apt update&lt;br /&gt;
sudo apt install timeshift&lt;br /&gt;
&lt;br /&gt;
# Check current snapshots&lt;br /&gt;
sudo timeshift --list&lt;br /&gt;
&lt;br /&gt;
# Create first snapshot manually (if none exist)&lt;br /&gt;
sudo timeshift --create --comments &amp;quot;Initial snapshot before disaster recovery setup&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Configuration&#039;&#039;&#039; (in Timeshift GUI or config):&lt;br /&gt;
* &#039;&#039;&#039;Storage location:&#039;&#039;&#039; &amp;lt;code&amp;gt;/raid1/timeshift/&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Device:&#039;&#039;&#039; &amp;lt;code&amp;gt;/dev/md1&amp;lt;/code&amp;gt; (RAID-1 array)&lt;br /&gt;
* &#039;&#039;&#039;Snapshot mode:&#039;&#039;&#039; RSYNC&lt;br /&gt;
* &#039;&#039;&#039;Auto-backup:&#039;&#039;&#039; Enabled, daily at 00:00&lt;br /&gt;
* &#039;&#039;&#039;Retention:&#039;&#039;&#039; At least 5 daily snapshots kept&lt;br /&gt;
&lt;br /&gt;
=== Restic (Remote Encrypted Backups) ===&lt;br /&gt;
&lt;br /&gt;
==== 1. Set Up Hetzner Storage Box ====&lt;br /&gt;
&lt;br /&gt;
If not already done:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Create Hetzner account&#039;&#039;&#039; at https://www.hetzner.com/storage/storage-box&lt;br /&gt;
# &#039;&#039;&#039;Order Storage Box&#039;&#039;&#039; (recommended: smallest size starts at €3.81/month)&lt;br /&gt;
# &#039;&#039;&#039;In Hetzner Console:&#039;&#039;&#039;&lt;br /&gt;
** Go to Storage Box settings&lt;br /&gt;
** Enable &amp;quot;External reachability&amp;quot; (required for SSH/SFTP)&lt;br /&gt;
** Upload your SSH public key (&amp;lt;code&amp;gt;~/.ssh/id_rsa.pub&amp;lt;/code&amp;gt;)&lt;br /&gt;
# &#039;&#039;&#039;Note your credentials:&#039;&#039;&#039;&lt;br /&gt;
** Server: &amp;lt;code&amp;gt;u540853.your-storagebox.de&amp;lt;/code&amp;gt; (your number varies)&lt;br /&gt;
&#039;&#039;&#039; Port: &#039;&#039;&#039;23** (not 22!)&lt;br /&gt;
** Username: &amp;lt;code&amp;gt;u540853&amp;lt;/code&amp;gt; (same as your account number)&lt;br /&gt;
&lt;br /&gt;
==== 2. Set Up SSH Access ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create SSH key if you don&#039;t have one&lt;br /&gt;
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa&lt;br /&gt;
&lt;br /&gt;
# Test SSH connection to Hetzner (port 23!)&lt;br /&gt;
ssh -p 23 u540853@u540853.your-storagebox.de &amp;quot;echo &#039;SSH working!&#039;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Should print: SSH working!&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== 3. Copy Backup Stack to /library ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# From your project directory&lt;br /&gt;
cp -r /home/mnw/Developer/SystemResiliency/backup-stack /library/&lt;br /&gt;
&lt;br /&gt;
cd /library/backup-stack&lt;br /&gt;
&lt;br /&gt;
# Create .env from example&lt;br /&gt;
cp .env.example .env&lt;br /&gt;
&lt;br /&gt;
# Edit with your Hetzner credentials and restic password&lt;br /&gt;
nano .env&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;.env file contents:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Hetzner Storage Box&lt;br /&gt;
HETZNER_REPO=&amp;quot;sftp:u540853@u540853.your-storagebox.de:./backups&amp;quot;&lt;br /&gt;
HETZNER_PASSWORD=&amp;quot;&amp;lt;generate-strong-restic-password-32-chars&amp;gt;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Alerts&lt;br /&gt;
NTFY_TOKEN=&amp;quot;&amp;lt;from Vaultwarden if you have one&amp;gt;&amp;quot;&lt;br /&gt;
NTFY_TOPIC=&amp;quot;backup-alerts&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== 4. Initialize Restic Repository ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/backup-stack&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Init Hetzner repository (will encrypt with password)&lt;br /&gt;
docker exec -e RESTIC_PASSWORD=&#039;&amp;lt;same-as-HETZNER_PASSWORD&amp;gt;&#039; restic-backup \&lt;br /&gt;
  restic init --repo sftp:u540853@u540853.your-storagebox.de:./backups&lt;br /&gt;
&lt;br /&gt;
# Output should show: &amp;quot;created restic repository&amp;quot; with repository ID&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== 5. Install Systemd Timers ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Copy timer definitions&lt;br /&gt;
sudo cp /library/backup-stack/systemd/*.service /etc/systemd/system/&lt;br /&gt;
sudo cp /library/backup-stack/systemd/*.timer /etc/systemd/system/&lt;br /&gt;
&lt;br /&gt;
# Reload systemd&lt;br /&gt;
sudo systemctl daemon-reload&lt;br /&gt;
&lt;br /&gt;
# Enable and start backup timer&lt;br /&gt;
sudo systemctl enable --now backup.timer&lt;br /&gt;
sudo systemctl enable --now backup-validate-daily.timer&lt;br /&gt;
sudo systemctl enable --now backup-validate-weekly.timer&lt;br /&gt;
&lt;br /&gt;
# Check status&lt;br /&gt;
systemctl list-timers | grep backup&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== 6. Test the Backup ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Run a manual backup to verify everything works&lt;br /&gt;
docker exec restic-backup /scripts/run-backup.sh&lt;br /&gt;
&lt;br /&gt;
# Watch the logs&lt;br /&gt;
docker logs -f restic-backup&lt;br /&gt;
&lt;br /&gt;
# Should show:&lt;br /&gt;
# - Database dumps created&lt;br /&gt;
# - Restic scanning files&lt;br /&gt;
# - Upload to Hetzner&lt;br /&gt;
# - Success message&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disaster Mode A: Local Recovery (Fast) ==&lt;br /&gt;
&lt;br /&gt;
=== Scenario: Lost a single file or small data ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example:&#039;&#039;&#039; Accidentally deleted a recipe from Mealie 2 days ago.&lt;br /&gt;
&lt;br /&gt;
==== Recovery Steps: ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. List available snapshots&lt;br /&gt;
sudo timeshift --list&lt;br /&gt;
&lt;br /&gt;
# 2. Mount the snapshot you want to restore from&lt;br /&gt;
# (Example: restore from 2026-05-11 daily snapshot)&lt;br /&gt;
sudo timeshift --mount /run/timeshift/latest&lt;br /&gt;
&lt;br /&gt;
# 3. File is now accessible at:&lt;br /&gt;
#    /run/timeshift/latest/library/mealie-app/mealie-data/recipes/&lt;br /&gt;
# Copy the file back to current system&lt;br /&gt;
cp /run/timeshift/latest/library/mealie-app/mealie-data/recipes/recipe.json \&lt;br /&gt;
   /library/mealie-app/mealie-data/recipes/&lt;br /&gt;
&lt;br /&gt;
# 4. Unmount&lt;br /&gt;
sudo timeshift --umount&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario: Need to restore entire service from snapshot ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example:&#039;&#039;&#039; Immich database corrupted, want to restore from snapshot.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Stop the service&lt;br /&gt;
cd /library/immich-app&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# 2. Mount snapshot&lt;br /&gt;
sudo timeshift --mount&lt;br /&gt;
&lt;br /&gt;
# 3. Restore the entire service directory&lt;br /&gt;
sudo rm -rf /library/immich-app&lt;br /&gt;
sudo cp -r /run/timeshift/latest/library/immich-app /library/&lt;br /&gt;
&lt;br /&gt;
# 4. Fix permissions (Docker runs as specific user)&lt;br /&gt;
sudo chown -R mnw:mnw /library/immich-app&lt;br /&gt;
&lt;br /&gt;
# 5. Restart service&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 6. Unmount snapshot&lt;br /&gt;
sudo timeshift --umount&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario: Full system restoration (if OS is still usable) ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example:&#039;&#039;&#039; Stronghold is working but some system files are corrupted.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# List snapshots from a few days ago&lt;br /&gt;
sudo timeshift --list&lt;br /&gt;
&lt;br /&gt;
# Restore from a known-good snapshot&lt;br /&gt;
# This restores EVERYTHING except /home, /var/lib/docker, /opt, /etc/hosts, etc.&lt;br /&gt;
sudo timeshift --restore --snapshot &amp;quot;2026-05-11_10-00-01&amp;quot; --skip-grub&lt;br /&gt;
&lt;br /&gt;
# Reboot after restoration&lt;br /&gt;
sudo reboot&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disaster Mode B: Full Recovery (After Drive Failure) ==&lt;br /&gt;
&lt;br /&gt;
=== Scenario: Hard drive failed, new hardware deployed ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Prerequisites:&#039;&#039;&#039;&lt;br /&gt;
* New hardware with OS installed (Ubuntu/Debian)&lt;br /&gt;
* Docker and docker-compose installed&lt;br /&gt;
* SSH key for Hetzner auth&lt;br /&gt;
* Restic password (from your secure notes)&lt;br /&gt;
&lt;br /&gt;
==== Recovery Steps: ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Create directories&lt;br /&gt;
mkdir -p /raid1&lt;br /&gt;
mkdir -p /library&lt;br /&gt;
mkdir -p /home/mnw/Developer/SystemResiliency&lt;br /&gt;
&lt;br /&gt;
# 2. Copy backup-stack to new system&lt;br /&gt;
scp -r username@old-system:/library/backup-stack /library/&lt;br /&gt;
&lt;br /&gt;
# 3. Update .env with correct passwords/credentials&lt;br /&gt;
nano /library/backup-stack/.env&lt;br /&gt;
&lt;br /&gt;
# 4. Start the Restic container&lt;br /&gt;
cd /library/backup-stack&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 5. List available backups in Hetzner&lt;br /&gt;
docker exec restic-backup restic snapshots --repo sftp:u540853@u540853.your-storagebox.de:./backups&lt;br /&gt;
&lt;br /&gt;
# 6. Restore the full /library directory from latest snapshot&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/restore \&lt;br /&gt;
  --repo sftp:u540853@u540853.your-storagebox.de:./backups \&lt;br /&gt;
  --include &amp;quot;/data/*&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# 7. Copy restored data to /library&lt;br /&gt;
sudo cp -r /tmp/restore/data/* /library/&lt;br /&gt;
&lt;br /&gt;
# 8. Restore /home/mnw&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/restore \&lt;br /&gt;
  --repo sftp:u540853@u540853.your-storagebox.de:./backups \&lt;br /&gt;
  --include &amp;quot;/data/home&amp;quot;&lt;br /&gt;
&lt;br /&gt;
sudo cp -r /tmp/restore/data/home/* /home/&lt;br /&gt;
&lt;br /&gt;
# 9. Fix permissions&lt;br /&gt;
sudo chown -R mnw:mnw /library&lt;br /&gt;
sudo chown -R mnw:mnw /home/mnw&lt;br /&gt;
&lt;br /&gt;
# 10. Restore PostgreSQL databases&lt;br /&gt;
# Each service (Authentik, Nextcloud, etc.) has a dump at /library/backup-stack/dumps/&lt;br /&gt;
# Use the restore procedure from each service&#039;s documentation&lt;br /&gt;
&lt;br /&gt;
# Example: Restore Authentik PostgreSQL&lt;br /&gt;
docker exec authentik-app_postgresql_1 pg_restore \&lt;br /&gt;
  /library/backup-stack/dumps/authentik-YYYYMMDD.sql &amp;lt; &amp;lt; (gunzip -c /path/to/dump)&lt;br /&gt;
&lt;br /&gt;
# 11. Start all services&lt;br /&gt;
for dir in /library/*-app/; do&lt;br /&gt;
  cd &amp;quot;$dir&amp;quot;&lt;br /&gt;
  docker compose up -d&lt;br /&gt;
done&lt;br /&gt;
&lt;br /&gt;
# 12. Verify critical services are running&lt;br /&gt;
docker ps | grep &amp;quot;server\|worker\|postgres&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario: Restore specific service from Restic ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Example:&#039;&#039;&#039; Only Immich needs recovery, not entire system.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/backup-stack&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Restore just Immich config and database dump&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/restore \&lt;br /&gt;
  --repo sftp:u540853@u540853.your-storagebox.de:./backups \&lt;br /&gt;
  --include &amp;quot;/data/immich*&amp;quot; \&lt;br /&gt;
  --include &amp;quot;/dumps/immich*&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Move to correct location&lt;br /&gt;
mv /tmp/restore/data/immich-config /library/immich-app&lt;br /&gt;
mv /tmp/restore/data/immich-photos /raid1/Consolidated/Picture/Immich-Photos&lt;br /&gt;
&lt;br /&gt;
# Restore the database&lt;br /&gt;
cd /library/immich-app&lt;br /&gt;
docker compose up -d immich_postgres&lt;br /&gt;
sleep 10&lt;br /&gt;
docker exec -i immich_postgres psql -U immich &amp;lt; &amp;lt;(gunzip -c /tmp/restore/dumps/immich-*.sql.gz)&lt;br /&gt;
&lt;br /&gt;
# Start full Immich&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Data Locations &amp;amp; Backup Coverage ==&lt;br /&gt;
&lt;br /&gt;
=== What&#039;s Backed Up (via Restic) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
Backed up to Hetzner:&lt;br /&gt;
├── /data/authentik/           — Authentik OAuth provider config&lt;br /&gt;
├── /data/vaultwarden/         — Password vault (SQLite + attachments)&lt;br /&gt;
├── /data/nextcloud/           — File sync config&lt;br /&gt;
├── /data/immich-config/       — Photo manager config&lt;br /&gt;
├── /data/immich-photos/       — All photos (149GB)&lt;br /&gt;
├── /data/jellyfin/            — /var/lib/jellyfin (plugins, root, subs; live DBs excluded)&lt;br /&gt;
├── /data/jellyfin-etc/        — /etc/jellyfin (system config)&lt;br /&gt;
├── /data/jellyfin-server/     — /library/jellyfin-server-app (active metadata 28G — see services/JELLYFIN.md)&lt;br /&gt;
├── /data/memos/               — Notes app&lt;br /&gt;
├── /data/linkwarden/          — Bookmark manager&lt;br /&gt;
├── /data/open-webui/          — AI chat interface&lt;br /&gt;
├── /data/matrix-chat/         — Synapse + bridge configs&lt;br /&gt;
├── /data/stalwart/            — Mail server data&lt;br /&gt;
├── /data/snappymail/          — Webmail data&lt;br /&gt;
├── /data/mailcow/             — Legacy mail (to be archived)&lt;br /&gt;
├── /data/developer/           — /home/mnw/Developer (7.9G)&lt;br /&gt;
├── /data/home/                — /home/mnw (selective: excludes .git/objects, node_modules, .venv)&lt;br /&gt;
├── /data/beets/               — Music library DB&lt;br /&gt;
├── /dumps/                    — PostgreSQL database dumps (daily)&lt;br /&gt;
│   ├── authentik-YYYYMMDD.sql.gz&lt;br /&gt;
│   ├── nextcloud-YYYYMMDD.sql.gz&lt;br /&gt;
│   ├── immich-YYYYMMDD.sql.gz&lt;br /&gt;
│   ├── linkwarden-YYYYMMDD.sql.gz&lt;br /&gt;
│   └── matrix-YYYYMMDD.sql.gz&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== What&#039;s NOT Backed Up (Re-acquirable) ===&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;/var/lib/docker/volumes&amp;lt;/code&amp;gt; ⚠️ (Timeshift excludes this; Restic needs to be updated to include)&lt;br /&gt;
* Jellyfin/Kaleidescape media files (movies, TV)&lt;br /&gt;
* Ollama models (re-downloadable)&lt;br /&gt;
* Steam games&lt;br /&gt;
* VirtualBox VMs&lt;br /&gt;
* node_modules, .venv, .git/objects, build artifacts&lt;br /&gt;
&lt;br /&gt;
=== Timeshift Snapshots ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
Stored on /raid1/timeshift/&lt;br /&gt;
├── 2025-05-03_17-15-00  (Backfill from old system)&lt;br /&gt;
├── 2026-05-03_11-52-11  (Backfill)&lt;br /&gt;
├── 2026-05-05_20-41-31  (Backfill)&lt;br /&gt;
├── 2026-05-05_21-36-57  (Backfill)&lt;br /&gt;
├── 2026-05-11_10-00-02  (Monthly snapshot)&lt;br /&gt;
├── 2026-05-12_00-00-01  (Daily snapshot)&lt;br /&gt;
├── 2026-05-12_10-00-01  (Weekly snapshot)&lt;br /&gt;
└── 2026-05-13_00-00-01  (Daily snapshot)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Each snapshot contains a full filesystem image (~100-300GB depending on changes).&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Critical Gotchas ==&lt;br /&gt;
&lt;br /&gt;
=== 1. ⚠️ Docker Volumes NOT in Timeshift ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Problem:&#039;&#039;&#039; Timeshift is configured to exclude &amp;lt;code&amp;gt;/var/lib/docker/volumes&amp;lt;/code&amp;gt;, which contains:&lt;br /&gt;
* PostgreSQL databases for Authentik, Nextcloud, Immich, Linkwarden, Matrix&lt;br /&gt;
* Docker-managed volumes&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;If you restore from Timeshift, you lose all database data.&#039;&#039;&#039; PostgreSQL will be empty.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Solution:&#039;&#039;&#039; Always use Restic for full recovery (it includes the databases via dumps), or manually restore PostgreSQL dumps.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Status:&#039;&#039;&#039; Known issue, needs fix in Timeshift config or backup-stack dumps.&lt;br /&gt;
&lt;br /&gt;
=== 2. Hetzner Port is 23, NOT 22 ===&lt;br /&gt;
&lt;br /&gt;
Many SSH configs default to port 22. Hetzner requires port 23.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Wrong:&#039;&#039;&#039; &amp;lt;code&amp;gt;ssh u540853@u540853.your-storagebox.de&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Right:&#039;&#039;&#039; &amp;lt;code&amp;gt;ssh -p 23 u540853@u540853.your-storagebox.de&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;ssh-config&amp;lt;/code&amp;gt; file in backup-stack handles this automatically.&lt;br /&gt;
&lt;br /&gt;
=== 3. Hetzner Relative Paths Required ===&lt;br /&gt;
&lt;br /&gt;
Hetzner SFTP doesn&#039;t support absolute paths for new directories.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Wrong:&#039;&#039;&#039; &amp;lt;code&amp;gt;sftp:u540853@...:/backups&amp;lt;/code&amp;gt; (fails with SSH_FX_FAILURE)&lt;br /&gt;
* &#039;&#039;&#039;Right:&#039;&#039;&#039; &amp;lt;code&amp;gt;sftp:u540853@....:./backups&amp;lt;/code&amp;gt; (relative to home directory)&lt;br /&gt;
&lt;br /&gt;
=== 4. Restic Password != Hetzner Password ===&lt;br /&gt;
&lt;br /&gt;
Two different passwords:&lt;br /&gt;
* &#039;&#039;&#039;Hetzner account password:&#039;&#039;&#039; Logs into Hetzner web console&lt;br /&gt;
* &#039;&#039;&#039;Restic password:&#039;&#039;&#039; Encrypts your backup repository&lt;br /&gt;
&lt;br /&gt;
Save both securely. If you lose the Restic password, your backups are unrecoverable.&lt;br /&gt;
&lt;br /&gt;
=== 5. SSH Key Permissions in Docker ===&lt;br /&gt;
&lt;br /&gt;
When mounting SSH keys into the Restic container, SSH is strict about ownership (must be 600 or 400).&lt;br /&gt;
&lt;br /&gt;
The docker-compose entrypoint handles this:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;yaml&amp;quot;&amp;gt;&lt;br /&gt;
entrypoint: [&amp;quot;/bin/sh&amp;quot;, &amp;quot;-c&amp;quot;, &amp;quot;mkdir -p /root/.ssh &amp;amp;&amp;amp; cp /tmp/ssh/&#039;&#039; /root/.ssh/ &amp;amp;&amp;amp; chmod 600 /root/.ssh/&#039;&#039; &amp;amp;&amp;amp; sleep infinity&amp;quot;]&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Don&#039;t modify without understanding SSH permission requirements.&lt;br /&gt;
&lt;br /&gt;
=== 6. PostgreSQL Dumps Staged Before Backup ===&lt;br /&gt;
&lt;br /&gt;
Database dumps are created at backup time by &amp;lt;code&amp;gt;run-backup.sh&amp;lt;/code&amp;gt;:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec &amp;lt;container&amp;gt; pg_dump -U &amp;lt;user&amp;gt; &amp;lt;db&amp;gt; | gzip &amp;gt; /library/backup-stack/dumps/&amp;lt;service&amp;gt;.sql.gz&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If a database is corrupted, the dump will fail, and that database won&#039;t be backed up. Check backup logs:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cat /library/backup-stack/dumps/backup-*.log | grep -i &amp;quot;error\|dump&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 7. Backup Window (03:00 CST) Must Not Overlap with Other Heavy Operations ===&lt;br /&gt;
&lt;br /&gt;
Backup runs daily at 03:00 CST. If you have other cron jobs or maintenance windows:&lt;br /&gt;
* Stagger them by at least 30 minutes&lt;br /&gt;
* Monitor systemd timers: &amp;lt;code&amp;gt;systemctl list-timers&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Concurrent heavy I/O can cause slowdowns or backup timeouts.&lt;br /&gt;
&lt;br /&gt;
=== 8. Restic Prune Deletes Old Snapshots Automatically ===&lt;br /&gt;
&lt;br /&gt;
Restic &amp;lt;code&amp;gt;prune&amp;lt;/code&amp;gt; runs after backup to clean up old snapshots per retention policy:&lt;br /&gt;
* Keep 7 daily, 4 weekly, 6 monthly&lt;br /&gt;
&lt;br /&gt;
If you manually delete recent snapshots (for testing), they may be pruned. Don&#039;t delete recent snapshots without understanding the implications.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Verification &amp;amp; Testing ==&lt;br /&gt;
&lt;br /&gt;
=== Daily Check: Backup Health ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check if backup completed successfully&lt;br /&gt;
docker logs restic-backup | tail -20 | grep -i &amp;quot;success\|error&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Check ntfy for backup alerts&lt;br /&gt;
curl https://ntfy.wilsoz.com/backup-alerts&lt;br /&gt;
&lt;br /&gt;
# List recent snapshots&lt;br /&gt;
docker exec restic-backup restic snapshots --limit 5&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Weekly Test: Restore a File ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Restore a small non-critical file to /tmp&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/test-restore \&lt;br /&gt;
  --include &amp;quot;/data/memos/*&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Verify files exist&lt;br /&gt;
ls -la /tmp/test-restore/data/memos/&lt;br /&gt;
&lt;br /&gt;
# Clean up&lt;br /&gt;
rm -rf /tmp/test-restore&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Monthly Test: Restore a Service Database ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Restore Linkwarden dump (small DB, ~50MB)&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/test-restore \&lt;br /&gt;
  --include &amp;quot;/dumps/linkwarden*&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Verify dump is valid&lt;br /&gt;
gunzip -t /tmp/test-restore/dumps/linkwarden-*.sql.gz&lt;br /&gt;
&lt;br /&gt;
# Should report: OK (no errors)&lt;br /&gt;
&lt;br /&gt;
# Clean up&lt;br /&gt;
rm -rf /tmp/test-restore&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Quarterly: Full System Restore Dry-Run ===&lt;br /&gt;
&lt;br /&gt;
Once per quarter, do a mock recovery to verify procedures:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Don&#039;t actually restore (too time-consuming), but verify:&lt;br /&gt;
# 1. Can list snapshots?&lt;br /&gt;
docker exec restic-backup restic snapshots --limit 1&lt;br /&gt;
&lt;br /&gt;
# 2. Can stat the repository (verify password works)?&lt;br /&gt;
docker exec restic-backup restic stats&lt;br /&gt;
&lt;br /&gt;
# 3. Can list files in a snapshot?&lt;br /&gt;
docker exec restic-backup restic ls latest | head -20&lt;br /&gt;
&lt;br /&gt;
# If all three pass, recovery is ready.&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Backup Failed ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Check logs:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Container logs&lt;br /&gt;
docker logs restic-backup | tail -50&lt;br /&gt;
&lt;br /&gt;
# Systemd log&lt;br /&gt;
sudo journalctl -u backup.service -n 50&lt;br /&gt;
&lt;br /&gt;
# Backup script log&lt;br /&gt;
ls -lht /library/backup-stack/dumps/backup-*.log&lt;br /&gt;
tail -100 /library/backup-stack/dumps/backup-*.log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Common causes:&#039;&#039;&#039;&lt;br /&gt;
* SSH key permission error → Run entrypoint again: &amp;lt;code&amp;gt;docker compose up -d&amp;lt;/code&amp;gt;&lt;br /&gt;
* Hetzner unreachable → Test SSH manually: &amp;lt;code&amp;gt;ssh -p 23 u540853@u540853.your-storagebox.de&amp;lt;/code&amp;gt;&lt;br /&gt;
* Database dump failed → Check Docker container: &amp;lt;code&amp;gt;docker ps | grep postgres&amp;lt;/code&amp;gt;&lt;br /&gt;
* Out of disk space → Check /library and /raid1: &amp;lt;code&amp;gt;df -h /library /raid1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Restore Is Slow ===&lt;br /&gt;
&lt;br /&gt;
Restic restores are network-bound from Hetzner. Typical speeds:&lt;br /&gt;
* Small file (&amp;lt; 100MB): 30 seconds&lt;br /&gt;
* Medium service (1-5GB): 5-15 minutes&lt;br /&gt;
* Large dataset (100+ GB): 1-3 hours&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;If slower than expected:&#039;&#039;&#039;&lt;br /&gt;
* Check network: &amp;lt;code&amp;gt;ping u540853.your-storagebox.de&amp;lt;/code&amp;gt; (port 23 doesn&#039;t reply to ping)&lt;br /&gt;
* Try SSH: &amp;lt;code&amp;gt;ssh -p 23 u540853@u540853.your-storagebox.de echo ok&amp;lt;/code&amp;gt;&lt;br /&gt;
* Check Restic cache: &amp;lt;code&amp;gt;du -sh /library/backup-stack/cache&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t Connect to Hetzner ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Test SSH connectivity&lt;br /&gt;
ssh -p 23 u540853@u540853.your-storagebox.de echo &amp;quot;Connected&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# If failed:&lt;br /&gt;
# 1. Check SSH key exists: ls -la ~/.ssh/id_rsa&lt;br /&gt;
# 2. Check Hetzner console: &amp;quot;External reachability&amp;quot; enabled?&lt;br /&gt;
# 3. Check your public key uploaded to Hetzner&lt;br /&gt;
# 4. Try with password instead: ssh -p 23 -o PubkeyAuthentication=no u540853@u540853.your-storagebox.de&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Restic Unlock Stuck ===&lt;br /&gt;
&lt;br /&gt;
If Restic process was killed, lock file may remain:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Remove lock&lt;br /&gt;
docker exec restic-backup restic unlock --repo sftp:u540853@...&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Maintenance ==&lt;br /&gt;
&lt;br /&gt;
=== Monthly: Review Snapshot Count ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo timeshift --list&lt;br /&gt;
&lt;br /&gt;
# Should show 5-8 snapshots. If too many:&lt;br /&gt;
sudo timeshift --delete-snapshot &amp;lt;snapshot_name&amp;gt;&lt;br /&gt;
&lt;br /&gt;
# If too few, create one manually:&lt;br /&gt;
sudo timeshift --create --comments &amp;quot;Manual snapshot for recovery testing&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Quarterly: Clean Up Restic Cache ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Cache can grow to 1-2GB over time&lt;br /&gt;
du -sh /library/backup-stack/cache&lt;br /&gt;
&lt;br /&gt;
# Prune cache (safe, will be rebuilt)&lt;br /&gt;
docker exec restic-backup restic cache-dir&lt;br /&gt;
docker exec restic-backup rm -rf /root/.cache/restic/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Annually: Update Restic Image ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/backup-stack&lt;br /&gt;
&lt;br /&gt;
# Update to latest Restic&lt;br /&gt;
docker compose pull&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Verify after update&lt;br /&gt;
docker exec restic-backup restic version&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Links &amp;amp; References ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Timeshift Docs:&#039;&#039;&#039; https://github.com/teejee2008/timeshift&lt;br /&gt;
* &#039;&#039;&#039;Restic Docs:&#039;&#039;&#039; https://restic.readthedocs.io/&lt;br /&gt;
* &#039;&#039;&#039;Hetzner Storage Box:&#039;&#039;&#039; https://www.hetzner.com/storage/storage-box&lt;br /&gt;
* &#039;&#039;&#039;Backup Strategy Overview:&#039;&#039;&#039; &amp;lt;code&amp;gt;/home/mnw/Developer/SystemResiliency/DISASTER_RECOVERY.md&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Backup Stack Code:&#039;&#039;&#039; &amp;lt;code&amp;gt;/home/mnw/Developer/SystemResiliency/backup-stack/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Related Services ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;All services&#039;&#039;&#039; — depend on this backup system for disaster recovery&lt;br /&gt;
* &#039;&#039;&#039;OpenBao&#039;&#039;&#039; — stores Hetzner SSH key and Restic password&lt;br /&gt;
* &#039;&#039;&#039;ntfy&#039;&#039;&#039; — receives backup alerts (success/failure notifications)&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL&#039;&#039;&#039; (in each service) — database dumps backed up daily&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/Omada&amp;diff=14</id>
		<title>Services/Omada</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/Omada&amp;diff=14"/>
		<updated>2026-07-05T18:16:20Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/OMADA.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/OMADA.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= TP-Link Omada Controller =&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Version:&#039;&#039;&#039; 6.1 (mbentley/omada-controller)  &lt;br /&gt;
&#039;&#039;&#039;Status:&#039;&#039;&#039; ✅ CRITICAL INFRASTRUCTURE — Network controller for all APs  &lt;br /&gt;
&#039;&#039;&#039;Last Updated:&#039;&#039;&#039; 2026-05-11&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Quick Reference ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Container&#039;&#039;&#039;&lt;br /&gt;
| omada-controller (mbentley/omada-controller:6.1)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Compose&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/omada-app/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Data&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/omada-app/data/&amp;lt;/code&amp;gt; (505MB)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Logs&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/omada-app/logs/&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Network&#039;&#039;&#039;&lt;br /&gt;
| host mode (required for AP discovery via multicast)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;UI Port&#039;&#039;&#039;&lt;br /&gt;
| 8043 (HTTPS), 8188 (HTTP)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Memory&#039;&#039;&#039;&lt;br /&gt;
| ~1.5GB (expected for MongoDB + Java controller)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Restart Policy&#039;&#039;&#039;&lt;br /&gt;
| unless-stopped&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Health Check&#039;&#039;&#039;&lt;br /&gt;
| healthy (docker ps shows status)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== What It Controls ==&lt;br /&gt;
&lt;br /&gt;
Omada is the &#039;&#039;&#039;centralized management platform&#039;&#039;&#039; for TP-Link enterprise WiFi:&lt;br /&gt;
* &#039;&#039;&#039;EAP610&#039;&#039;&#039; (or other managed APs) — firmware updates, channel optimization, roaming&lt;br /&gt;
* &#039;&#039;&#039;Network policies&#039;&#039;&#039; — SSID config, authentication, bandwidth limits&lt;br /&gt;
* &#039;&#039;&#039;Analytics&#039;&#039;&#039; — traffic, client health, interference detection&lt;br /&gt;
* &#039;&#039;&#039;Auto-HA&#039;&#039;&#039; (if configured) — automatic failover between controllers&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Loss of Omada = Manual AP management only&#039;&#039;&#039; — devices will continue operating on last-known config, but no remote control.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Backup &amp;amp; Recovery ==&lt;br /&gt;
&lt;br /&gt;
=== Current Backup Status ===&lt;br /&gt;
✅ &#039;&#039;&#039;Included in daily restic backup&#039;&#039;&#039;&lt;br /&gt;
* Path: &amp;lt;code&amp;gt;/data/omada&amp;lt;/code&amp;gt; in backup scope (docker-compose.yml line 37)&lt;br /&gt;
* Data persists in: &amp;lt;code&amp;gt;/library/omada-app/data/&amp;lt;/code&amp;gt; (MongoDB + config)&lt;br /&gt;
* Size: ~505MB (small, incremental backups efficient)&lt;br /&gt;
* Database: embedded MongoDB (not separate container)&lt;br /&gt;
&lt;br /&gt;
=== Restore Procedure ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Single service restore (controller crashed):&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Stop the container&lt;br /&gt;
docker compose -f /library/omada-app/docker-compose.yml down&lt;br /&gt;
&lt;br /&gt;
# 2. Restore data from latest snapshot&lt;br /&gt;
docker exec restic-backup sh -c \&lt;br /&gt;
  &#039;export RESTIC_REPOSITORY=$HETZNER_REPO; export RESTIC_PASSWORD=$HETZNER_PASSWORD; \&lt;br /&gt;
   restic restore latest --target /tmp/restore --include /data/omada&#039;&lt;br /&gt;
&lt;br /&gt;
# 3. Copy restored data&lt;br /&gt;
sudo cp -r /tmp/restore/data/omada /library/omada-app/data.restored&lt;br /&gt;
sudo rm -rf /library/omada-app/data&lt;br /&gt;
sudo mv /library/omada-app/data.restored /library/omada-app/data&lt;br /&gt;
sudo chown -R 508:508 /library/omada-app/data&lt;br /&gt;
&lt;br /&gt;
# 4. Restart&lt;br /&gt;
docker compose -f /library/omada-app/docker-compose.yml up -d&lt;br /&gt;
&lt;br /&gt;
# 5. Verify health&lt;br /&gt;
docker ps | grep omada-controller  # should show &amp;quot;healthy&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Full Disaster Recovery ===&lt;br /&gt;
If hardware is lost, Omada config is recoverable via:&lt;br /&gt;
# &#039;&#039;&#039;MongoDB backup&#039;&#039;&#039; in &amp;lt;code&amp;gt;/library/omada-app/data/&amp;lt;/code&amp;gt; — restores all device configs, SSIDs, policies&lt;br /&gt;
# &#039;&#039;&#039;Re-adopt APs&#039;&#039;&#039; — once controller is back, press reset button on each AP, it rejoins the controller&lt;br /&gt;
# &#039;&#039;&#039;Fabric IP recovery&#039;&#039;&#039; — if you have Omada Fabric configured, endpoints will auto-rejoin after controller restart&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Memory Pressure ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why 1.5GB?&#039;&#039;&#039;&lt;br /&gt;
* Omada bundles &#039;&#039;&#039;MongoDB 5.0&#039;&#039;&#039; for config storage&lt;br /&gt;
* Java-based controller (OpenJDK 11+)&lt;br /&gt;
* Holds all device telemetry, client sessions, audit logs in memory&lt;br /&gt;
* Normal for enterprise controller this size&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;This is expected and required&#039;&#039;&#039; — memory is justified by critical infrastructure role.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Key Ports ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Port&lt;br /&gt;
! Protocol&lt;br /&gt;
! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| 8043&lt;br /&gt;
| HTTPS&lt;br /&gt;
| Web UI + API (admin.omada.local)&lt;br /&gt;
|-&lt;br /&gt;
| 8188&lt;br /&gt;
| HTTP&lt;br /&gt;
| Portal (auto-redirects to HTTPS)&lt;br /&gt;
|-&lt;br /&gt;
| 29810-29820&lt;br /&gt;
| TCP&lt;br /&gt;
| Device communication (APs, switches)&lt;br /&gt;
|-&lt;br /&gt;
| 27001-27999&lt;br /&gt;
| TCP&lt;br /&gt;
| MongoDB internal replication&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Operational Checks ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Verify controller is healthy:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check container status&lt;br /&gt;
docker ps | grep omada-controller&lt;br /&gt;
&lt;br /&gt;
# Check web UI (if SSH tunneled to port 8043)&lt;br /&gt;
curl -k https://localhost:8043/login  # should respond with login page&lt;br /&gt;
&lt;br /&gt;
# Monitor memory (expect 1.5GB)&lt;br /&gt;
docker stats omada-controller&lt;br /&gt;
&lt;br /&gt;
# View recent logs&lt;br /&gt;
docker logs --tail=50 omada-controller | grep -E &amp;quot;ERROR|WARN|Exception&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Verify APs are connected:&#039;&#039;&#039;&lt;br /&gt;
# Open https://admin.omada.local:8043 (or via Tailscale if remote)&lt;br /&gt;
# Go to &#039;&#039;&#039;Devices&#039;&#039;&#039; — should show all EAP610 APs as &amp;quot;Connected&amp;quot;&lt;br /&gt;
# If AP shows &amp;quot;Disconnected&amp;quot;, check:&lt;br /&gt;
** AP has network connectivity (ping it)&lt;br /&gt;
** AP is on same LAN or can reach controller IP:29810&lt;br /&gt;
** Controller logs for adoption errors: &amp;lt;code&amp;gt;docker logs omada-controller | grep adopt&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Upgrade Path ==&lt;br /&gt;
&lt;br /&gt;
Current: &#039;&#039;&#039;6.1&#039;&#039;&#039; (latest as of 2026-05-11)&lt;br /&gt;
&lt;br /&gt;
Watchtower (if enabled for this image) will auto-update. If manual:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/omada-app&lt;br /&gt;
docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Caution:&#039;&#039;&#039; Always backup before upgrading (restic snapshot is enough).&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Known Issues / Gotchas ==&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Host network mode required&#039;&#039;&#039; — Omada needs raw access to network broadcasts for AP discovery. Cannot use bridge networking.&lt;br /&gt;
# &#039;&#039;&#039;MongoDB locked on restart&#039;&#039;&#039; — If controller crashes mid-operation, MongoDB lock file may persist. Container entrypoint handles cleanup, but if stuck: &amp;lt;code&amp;gt;rm /library/omada-app/data/db/mongod.lock&amp;lt;/code&amp;gt;&lt;br /&gt;
# &#039;&#039;&#039;Fabric timeout during big config changes&#039;&#039;&#039; — if Fabric mesh is configured, topology recalculation can take 2-5 minutes. Don&#039;t panic if APs briefly disconnect.&lt;br /&gt;
# &#039;&#039;&#039;TZ environment variable&#039;&#039;&#039; — set to &amp;lt;code&amp;gt;America/Chicago&amp;lt;/code&amp;gt; for CST. Check via UI: Settings → General. If wrong, logs won&#039;t align with system time.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Documentation References ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Omada API:&#039;&#039;&#039; https://documenter.getpostman.com/view/6114396 (community Postman docs)&lt;br /&gt;
* &#039;&#039;&#039;TP-Link Omada SDN:&#039;&#039;&#039; https://www.tp-link.com/en/business/omada-sdn-controller/ (official)&lt;br /&gt;
* &#039;&#039;&#039;Docker image:&#039;&#039;&#039; https://hub.docker.com/r/mbentley/omada-controller (custom image, better Linux support than official)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Alerting ==&lt;br /&gt;
&lt;br /&gt;
Should monitor for:&lt;br /&gt;
* ✅ Container health check (Uptime Kuma can ping :8043)&lt;br /&gt;
* ✅ Memory spike (&amp;gt;2GB = investigate, might indicate memory leak)&lt;br /&gt;
* ✅ AP disconnects (if &amp;gt;50% offline, controller may be hung)&lt;br /&gt;
&lt;br /&gt;
Current monitoring: None configured (add to Uptime Kuma if needed)&lt;br /&gt;
&lt;br /&gt;
Suggested: Create http monitor for https://localhost:8043/login returning 200 status&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/AudioBookshelf&amp;diff=13</id>
		<title>Services/AudioBookshelf</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/AudioBookshelf&amp;diff=13"/>
		<updated>2026-07-05T18:16:20Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/AUDIOBOOKSHELF.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/AUDIOBOOKSHELF.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= AudioBookshelf =&lt;br /&gt;
&lt;br /&gt;
== Service Info ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Type:&#039;&#039;&#039; Native systemd (not Docker)&lt;br /&gt;
* &#039;&#039;&#039;Version:&#039;&#039;&#039; 2.33.2&lt;br /&gt;
* &#039;&#039;&#039;Port:&#039;&#039;&#039; 13378&lt;br /&gt;
* &#039;&#039;&#039;Public URL:&#039;&#039;&#039; https://listen.wilsoz.com (proxied via VPS nginx → Tailscale → stronghold)&lt;br /&gt;
* &#039;&#039;&#039;Config:&#039;&#039;&#039; &amp;lt;code&amp;gt;/etc/default/audiobookshelf&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Data:&#039;&#039;&#039; &amp;lt;code&amp;gt;/usr/share/audiobookshelf/&amp;lt;/code&amp;gt; (config/, metadata/)&lt;br /&gt;
* &#039;&#039;&#039;DB:&#039;&#039;&#039; &amp;lt;code&amp;gt;/usr/share/audiobookshelf/config/absdatabase.sqlite&amp;lt;/code&amp;gt; (owned: audiobookshelf:audiobookshelf)&lt;br /&gt;
* &#039;&#039;&#039;Service user:&#039;&#039;&#039; &amp;lt;code&amp;gt;audiobookshelf&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Backups:&#039;&#039;&#039; &amp;lt;code&amp;gt;/raid1/Consolidated/AudioBookshelfBackup&amp;lt;/code&amp;gt;, nightly 1:30 AM, 7 kept&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
systemctl status audiobookshelf&lt;br /&gt;
systemctl restart audiobookshelf&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Authentik OIDC ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Provider:&#039;&#039;&#039; pk 18, name: &amp;quot;AudioBookshelf OAuth&amp;quot;, slug: audiobookshelf&lt;br /&gt;
* &#039;&#039;&#039;Application slug:&#039;&#039;&#039; audiobookshelf, URL: https://listen.wilsoz.com&lt;br /&gt;
* &#039;&#039;&#039;Client ID:&#039;&#039;&#039; in OpenBao at &amp;lt;code&amp;gt;secret/audiobookshelf&amp;lt;/code&amp;gt; field &amp;lt;code&amp;gt;oidc-client-id&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Client Secret:&#039;&#039;&#039; in OpenBao at &amp;lt;code&amp;gt;secret/audiobookshelf&amp;lt;/code&amp;gt; field &amp;lt;code&amp;gt;oidc-client-secret&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;Match existing accounts by:&#039;&#039;&#039; username&lt;br /&gt;
* &#039;&#039;&#039;Auto-register new users:&#039;&#039;&#039; yes&lt;br /&gt;
* &#039;&#039;&#039;Auto-launch SSO:&#039;&#039;&#039; no (local login still shown as fallback)&lt;br /&gt;
&lt;br /&gt;
=== Redirect URIs registered in Authentik ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
https://listen.wilsoz.com/auth/openid/callback&lt;br /&gt;
https://listen.wilsoz.com/audiobookshelf/auth/openid/callback&lt;br /&gt;
audiobookshelf://oauth&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== OIDC Endpoints (from Authentik discovery) ===&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
Issuer:    https://key.wilsoz.com/application/o/audiobookshelf/&lt;br /&gt;
Authorize: https://key.wilsoz.com/application/o/authorize/&lt;br /&gt;
Token:     https://key.wilsoz.com/application/o/token/&lt;br /&gt;
UserInfo:  https://key.wilsoz.com/application/o/userinfo/&lt;br /&gt;
JWKS:      https://key.wilsoz.com/application/o/audiobookshelf/jwks/&lt;br /&gt;
Logout:    https://key.wilsoz.com/application/o/audiobookshelf/end-session/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Gotchas ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;OIDC config lives in SQLite, not a config file.&#039;&#039;&#039; Settings are in the &amp;lt;code&amp;gt;settings&amp;lt;/code&amp;gt; table under key &amp;lt;code&amp;gt;server-settings&amp;lt;/code&amp;gt; as a JSON blob. ABS must be stopped before writing (owned by audiobookshelf user):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
systemctl stop audiobookshelf&lt;br /&gt;
sudo -u audiobookshelf sqlite3 /usr/share/audiobookshelf/config/absdatabase.sqlite \&lt;br /&gt;
  &amp;quot;SELECT value FROM settings WHERE key=&#039;server-settings&#039;;&amp;quot; | python3 -m json.tool&lt;br /&gt;
systemctl start audiobookshelf&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;authOpenIDSubfolderForRedirectURLs&amp;lt;/code&amp;gt; must be set to &amp;lt;code&amp;gt;&amp;quot;&amp;quot;&amp;lt;/code&amp;gt;&#039;&#039;&#039; (empty string, not absent). If this field is missing from the settings JSON, ABS sends &amp;lt;code&amp;gt;https://listen.wilsoz.com/undefined/auth/openid/callback&amp;lt;/code&amp;gt; as the redirect URI — a JS &amp;lt;code&amp;gt;undefined&amp;lt;/code&amp;gt; coerced to string. This field is normally set by the v2.17.4 migration, but only if OIDC was already enabled at migration time. If you configure OIDC by writing directly to the DB, add this field manually.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Authentik authorize/token/userinfo URLs are shared, not per-provider.&#039;&#039;&#039; Even with &amp;lt;code&amp;gt;issuer_mode: per_provider&amp;lt;/code&amp;gt;, the actual endpoints are:&lt;br /&gt;
* &amp;lt;code&amp;gt;/application/o/authorize/&amp;lt;/code&amp;gt; (not &amp;lt;code&amp;gt;/application/o/audiobookshelf/authorize/&amp;lt;/code&amp;gt;)&lt;br /&gt;
* &amp;lt;code&amp;gt;/application/o/token/&amp;lt;/code&amp;gt; (not &amp;lt;code&amp;gt;/application/o/audiobookshelf/token/&amp;lt;/code&amp;gt;)&lt;br /&gt;
* &amp;lt;code&amp;gt;/application/o/userinfo/&amp;lt;/code&amp;gt; (not &amp;lt;code&amp;gt;/application/o/audiobookshelf/userinfo/&amp;lt;/code&amp;gt;)&lt;br /&gt;
&lt;br /&gt;
Only the issuer, JWKS, and end-session URLs are per-provider. Use the discovery document to get the correct URLs:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s https://key.wilsoz.com/application/o/audiobookshelf/.well-known/openid-configuration | python3 -m json.tool&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Authentik API PATCH returns 403 for providers&#039;&#039;&#039; — use PUT with the full provider object instead.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Python &amp;lt;code&amp;gt;urllib&amp;lt;/code&amp;gt; drops Authorization header on HTTPS redirect&#039;&#039;&#039; — use &amp;lt;code&amp;gt;subprocess&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;curl&amp;lt;/code&amp;gt; for Authentik API calls from Python scripts.&lt;br /&gt;
&lt;br /&gt;
== Users ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Username&lt;br /&gt;
! Email&lt;br /&gt;
! Type&lt;br /&gt;
|-&lt;br /&gt;
| admin&lt;br /&gt;
| (none)&lt;br /&gt;
| root&lt;br /&gt;
|-&lt;br /&gt;
| marcus&lt;br /&gt;
| (none)&lt;br /&gt;
| user&lt;br /&gt;
|-&lt;br /&gt;
| travis&lt;br /&gt;
| (none)&lt;br /&gt;
| user&lt;br /&gt;
|-&lt;br /&gt;
| mehone&lt;br /&gt;
| Mehonesleshi1989@gmail.com&lt;br /&gt;
| user&lt;br /&gt;
|-&lt;br /&gt;
| stefany&lt;br /&gt;
| (none)&lt;br /&gt;
| user&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Note: marcus and admin have no email stored — OIDC match is by username, not email.&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/SnappyMail&amp;diff=12</id>
		<title>Services/SnappyMail</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/SnappyMail&amp;diff=12"/>
		<updated>2026-07-05T18:16:20Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/SNAPPYMAIL.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/SNAPPYMAIL.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= SnappyMail Webmail =&lt;br /&gt;
&lt;br /&gt;
== Container &amp;amp; Files ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| Container&lt;br /&gt;
| &amp;lt;code&amp;gt;snappymail&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Compose&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/snappymail-app/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Data dir&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/snappymail-app/data/&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Image&lt;br /&gt;
| &amp;lt;code&amp;gt;djmaze/snappymail:latest&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Internal port&lt;br /&gt;
| &amp;lt;code&amp;gt;8888&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| External port&lt;br /&gt;
| &amp;lt;code&amp;gt;8890&amp;lt;/code&amp;gt; → proxied via VPS nginx&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/snappymail-app &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
docker logs --tail=50 snappymail 2&amp;gt;&amp;amp;1 | strings&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Architecture Note — Unified vs Multi-Account ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;SnappyMail connected accounts ≠ unified inbox.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Adding external accounts (Gmail, iCloud, SDF) in SnappyMail Settings → Accounts shows them as separate silos side-by-side. There is no combined view.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;The right approach for a unified inbox&#039;&#039;&#039; is to use &#039;&#039;&#039;getmail&#039;&#039;&#039; to pull all external accounts into Stalwart, then connect SnappyMail (and phones/clients) only to Stalwart. Sieve rules on Stalwart do the filtering. This way:&lt;br /&gt;
* One IMAP server to connect to from anywhere&lt;br /&gt;
* All mail filtered by sieve on arrival&lt;br /&gt;
* SnappyMail just sees one account with well-organized folders&lt;br /&gt;
&lt;br /&gt;
Use SnappyMail&#039;s &amp;quot;additional accounts&amp;quot; feature only for occasional read-only access to an external account, not as the primary mail flow mechanism.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Critical: MTU Must Be 1280 ==&lt;br /&gt;
&lt;br /&gt;
The host&#039;s surfshark WireGuard VPN (surfshark_wg) has MTU 1280. All Docker bridge networks must match or large TLS packets (Server Hello + cert chain, ~4–6 KB) get silently dropped mid-handshake. &#039;&#039;&#039;Without MTU 1280, all external IMAP SSL connections hang for ~2 minutes then reset.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt; already has this fix:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;yaml&amp;quot;&amp;gt;&lt;br /&gt;
networks:&lt;br /&gt;
  default:&lt;br /&gt;
    driver: bridge&lt;br /&gt;
    driver_opts:&lt;br /&gt;
      com.docker.network.driver.mtu: 1280&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If SnappyMail is redeployed or the network is recreated, verify MTU with:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker network inspect snappymail-app_default | grep -i mtu&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
After changing docker-compose.yml, you must &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt; — &amp;lt;code&amp;gt;restart&amp;lt;/code&amp;gt; alone does NOT recreate the network.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Domain Config Files ==&lt;br /&gt;
&lt;br /&gt;
Path: &amp;lt;code&amp;gt;/library/snappymail-app/data/_data_/_default_/domains/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
One JSON file per domain. Controls IMAP/SMTP server settings for that domain&#039;s accounts.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Ownership:&#039;&#039;&#039; Files must be owned by &amp;lt;code&amp;gt;uid 82 / gid 82&amp;lt;/code&amp;gt; (container&#039;s www-data). If created as root, SnappyMail can&#039;t read them and shows &amp;quot;Domain is not allowed&amp;quot; or &amp;quot;no domain configuration&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo chown 82:82 /library/snappymail-app/data/_data_/_default_/domains/*.json&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Active domain configs:&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! File&lt;br /&gt;
! Server&lt;br /&gt;
! Notes&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;wilsoz.com.json&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;host.docker.internal&amp;lt;/code&amp;gt;&lt;br /&gt;
| Stalwart — primary account&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;craniumslows.com.json&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;host.docker.internal&amp;lt;/code&amp;gt;&lt;br /&gt;
| Stalwart — secondary address&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;gmail.com.json&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;imap.gmail.com:993&amp;lt;/code&amp;gt;&lt;br /&gt;
| Requires App Password&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;icloud.com.json&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;imap.mail.me.com:993&amp;lt;/code&amp;gt;&lt;br /&gt;
| Requires App Password&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;me.com.json&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;imap.mail.me.com:993&amp;lt;/code&amp;gt;&lt;br /&gt;
| Same server as iCloud&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;sdf.org.json&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;mx.sdf.org:993&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;shortLogin: true, lowerLogin: true&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Editing domain configs:&#039;&#039;&#039; Do NOT bind-mount individual files — the container entrypoint does &amp;lt;code&amp;gt;sed -i&amp;lt;/code&amp;gt; substitutions on its config files, which fails with &amp;quot;Resource busy&amp;quot; on bind-mounted files. Bind-mount the whole &amp;lt;code&amp;gt;data/&amp;lt;/code&amp;gt; directory (already the case).&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disabled Domains List ==&lt;br /&gt;
&lt;br /&gt;
Path: &amp;lt;code&amp;gt;/library/snappymail-app/data/_data_/_default_/domains/disabled&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Plain text, one domain per line. Domains listed here cannot be added as accounts.&lt;br /&gt;
&lt;br /&gt;
Current contents (as of 2026-02-22):&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
outlook.com&lt;br /&gt;
qq.com&lt;br /&gt;
yahoo.com&lt;br /&gt;
hotmail.com&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;gmail.com&amp;lt;/code&amp;gt; was in here by default — removed so users can add Gmail accounts.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Stalwart Connection (wilsoz.com / craniumslows.com) ==&lt;br /&gt;
&lt;br /&gt;
SnappyMail connects to Stalwart via &amp;lt;code&amp;gt;host.docker.internal&amp;lt;/code&amp;gt; (resolves to the Docker bridge gateway → host → Stalwart port 993/587).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;json&amp;quot;&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;IMAP&amp;quot;: { &amp;quot;host&amp;quot;: &amp;quot;host.docker.internal&amp;quot;, &amp;quot;port&amp;quot;: 993, &amp;quot;type&amp;quot;: 1 },&lt;br /&gt;
  &amp;quot;SMTP&amp;quot;: { &amp;quot;host&amp;quot;: &amp;quot;host.docker.internal&amp;quot;, &amp;quot;port&amp;quot;: 587, &amp;quot;type&amp;quot;: 2 }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;extra_hosts: [&amp;quot;host.docker.internal:host-gateway&amp;quot;]&amp;lt;/code&amp;gt; in docker-compose.yml enables this.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== External Account App Passwords ==&lt;br /&gt;
&lt;br /&gt;
For reference (these are app-specific passwords, not account passwords):&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Account&lt;br /&gt;
! IMAP Server&lt;br /&gt;
! App Password&lt;br /&gt;
|-&lt;br /&gt;
| craniumslows@gmail.com&lt;br /&gt;
| imap.gmail.com:993&lt;br /&gt;
| &amp;lt;code&amp;gt;jeuwezdxaoaysvni&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| thewilson@gmail.com&lt;br /&gt;
| imap.gmail.com:993&lt;br /&gt;
| &amp;lt;code&amp;gt;wikjuwnmklkeisid&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| marcusthewilson@me.com&lt;br /&gt;
| imap.mail.me.com:993&lt;br /&gt;
| &amp;lt;code&amp;gt;pekx-kvid-iopx-lhyy&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| mnw@sdf.org&lt;br /&gt;
| mx.sdf.org:993&lt;br /&gt;
| (SDF account password)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;quot;Can&#039;t connect to host ssl://...&amp;quot;&#039;&#039;&#039;&lt;br /&gt;
* First check: MTU. Run &amp;lt;code&amp;gt;docker network inspect snappymail-app_default | grep -i mtu&amp;lt;/code&amp;gt; — must be 1280.&lt;br /&gt;
* Test SSL from inside container: &amp;lt;code&amp;gt;docker exec snappymail php -r &amp;quot;...stream_socket_client(&#039;ssl://imap.gmail.com:993&#039;...)&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
* If MTU is fine, test from host: &amp;lt;code&amp;gt;curl -v --max-time 10 imaps://imap.gmail.com:993&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;quot;Domain is not allowed&amp;quot; / &amp;quot;no domain configuration&amp;quot;&#039;&#039;&#039;&lt;br /&gt;
* Check &amp;lt;code&amp;gt;disabled&amp;lt;/code&amp;gt; file — is the domain listed there?&lt;br /&gt;
* Check if a &amp;lt;code&amp;gt;.json&amp;lt;/code&amp;gt; file exists for that domain&lt;br /&gt;
* Check ownership: &amp;lt;code&amp;gt;sudo ls -la /library/snappymail-app/data/_data_/_default_/domains/&amp;lt;/code&amp;gt;&lt;br /&gt;
* Fix: &amp;lt;code&amp;gt;sudo chown 82:82 /library/snappymail-app/data/_data_/_default_/domains/&amp;lt;domain&amp;gt;.json&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Admin panel&#039;&#039;&#039;&lt;br /&gt;
* URL: &amp;lt;code&amp;gt;http://localhost:8890/?admin&amp;lt;/code&amp;gt; (or via VPS proxy)&lt;br /&gt;
* Default admin password set on first run, stored in &amp;lt;code&amp;gt;data/_data_/_default_/configs/application.ini&amp;lt;/code&amp;gt;&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/Mealie&amp;diff=11</id>
		<title>Services/Mealie</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/Mealie&amp;diff=11"/>
		<updated>2026-07-05T18:16:19Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/MEALIE.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/MEALIE.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Mealie — Recipe Manager =&lt;br /&gt;
&lt;br /&gt;
Complete setup &amp;amp; operations guide for Mealie (recipe management app) with Authentik OAuth integration.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Mealie&#039;&#039;&#039; is a self-hosted recipe management app. We run it on Stronghold with:&lt;br /&gt;
* Authentik OAuth2 for user authentication&lt;br /&gt;
* Group-based access control (admin vs user roles)&lt;br /&gt;
* HTTPS via Cloudflare (cook.wilsoz.com)&lt;br /&gt;
* Persistent data storage in Docker volume&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Users:&#039;&#039;&#039; marcus (admin via Authentik)  &lt;br /&gt;
&#039;&#039;&#039;Access:&#039;&#039;&#039; https://cook.wilsoz.com&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Service Information ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Property&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;URL&#039;&#039;&#039;&lt;br /&gt;
| https://cook.wilsoz.com&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Container name&#039;&#039;&#039;&lt;br /&gt;
| mealie&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Compose file&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/mealie-app/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Data volume&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;mealie-app_mealie-data:/app/data&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Internal port&#039;&#039;&#039;&lt;br /&gt;
| 9000&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Host port&#039;&#039;&#039;&lt;br /&gt;
| 9925&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Network&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;mealie-app_mealie&amp;lt;/code&amp;gt; (MTU 1280)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Image&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;ghcr.io/mealie-recipes/mealie:latest&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Initial Setup from Scratch ==&lt;br /&gt;
&lt;br /&gt;
=== 1. Create Directory &amp;amp; Files ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p /library/mealie-app&lt;br /&gt;
cd /library/mealie-app&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Create docker-compose.yml ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;yaml&amp;quot;&amp;gt;&lt;br /&gt;
version: &amp;quot;3.8&amp;quot;&lt;br /&gt;
services:&lt;br /&gt;
  mealie:&lt;br /&gt;
    image: ghcr.io/mealie-recipes/mealie:latest&lt;br /&gt;
    container_name: mealie&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
    volumes:&lt;br /&gt;
      - mealie-data:/app/data&lt;br /&gt;
    ports:&lt;br /&gt;
      - &amp;quot;9925:9000&amp;quot;&lt;br /&gt;
    networks:&lt;br /&gt;
      - mealie&lt;br /&gt;
&lt;br /&gt;
volumes:&lt;br /&gt;
  mealie-data:&lt;br /&gt;
&lt;br /&gt;
networks:&lt;br /&gt;
  mealie:&lt;br /&gt;
    driver: bridge&lt;br /&gt;
    driver_opts:&lt;br /&gt;
      com.docker.network.driver.mtu: 1280&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. Create .env File ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# App Configuration&lt;br /&gt;
PUID=1000&lt;br /&gt;
PGID=1000&lt;br /&gt;
TZ=America/Chicago&lt;br /&gt;
BASE_URL=https://cook.wilsoz.com&lt;br /&gt;
ALLOW_SIGNUP=false&lt;br /&gt;
&lt;br /&gt;
# OIDC Authentication (Authentik)&lt;br /&gt;
OIDC_AUTH_ENABLED=true&lt;br /&gt;
OIDC_PROVIDER_NAME=Authentik&lt;br /&gt;
OIDC_CONFIGURATION_URL=https://key.wilsoz.com/application/o/mealie/.well-known/openid-configuration&lt;br /&gt;
OIDC_CLIENT_ID=3fUfT5FALoAqvWHzqiD0eLNYKd3fsyYjdUkEUVy5&lt;br /&gt;
OIDC_CLIENT_SECRET=D5IbtoxMgx6Kkmry296bXUz6G3NcvzT70wIBrr7RFu8&lt;br /&gt;
OIDC_SIGNUP_ENABLED=true&lt;br /&gt;
OIDC_AUTO_REDIRECT=false&lt;br /&gt;
OIDC_USER_GROUP=family-and-friends&lt;br /&gt;
OIDC_ADMIN_GROUP=authentik Admins&lt;br /&gt;
OIDC_GROUPS_CLAIM=groups&lt;br /&gt;
OIDC_USER_CLAIM=email&lt;br /&gt;
OIDC_NAME_CLAIM=name&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4. Set Up Authentik OAuth Provider ===&lt;br /&gt;
&lt;br /&gt;
==== Create OAuth2 Provider in Authentik ====&lt;br /&gt;
&lt;br /&gt;
# Go to https://key.wilsoz.com/if/admin/&lt;br /&gt;
# &#039;&#039;&#039;Applications → Providers → Create OAuth2/OpenID Provider&#039;&#039;&#039;&lt;br /&gt;
# Fill in:&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Name:** mealie-oauth&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Authorization flow:** (default-authentication-flow)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Client type:** Confidential&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Redirect URIs:** (one per line)&lt;br /&gt;
     ```&lt;br /&gt;
     https://cook.wilsoz.com/login&lt;br /&gt;
     https://cook.wilsoz.com/login?direct=1&lt;br /&gt;
     ```&lt;br /&gt;
# &#039;&#039;&#039;Save&#039;&#039;&#039; — note the &#039;&#039;&#039;Client ID&#039;&#039;&#039; and &#039;&#039;&#039;Client Secret&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==== Configure Provider: Signing Key ====&lt;br /&gt;
&lt;br /&gt;
⚠️ &#039;&#039;&#039;CRITICAL:&#039;&#039;&#039; Assign the signing key or JWKS endpoint will return &amp;lt;code&amp;gt;{}&amp;lt;/code&amp;gt; and OAuth will fail.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
from authentik.crypto.models import CertificateKeyPair&lt;br /&gt;
&lt;br /&gt;
p = OAuth2Provider.objects.get(name=&#039;mealie-oauth&#039;)&lt;br /&gt;
jwt_key = CertificateKeyPair.objects.get(name=&#039;authentik Internal JWT Certificate&#039;)&lt;br /&gt;
p.signing_key = jwt_key&lt;br /&gt;
p.save()&lt;br /&gt;
print(&#039;Assigned JWT Certificate signing key&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Why JWT Certificate?&#039;&#039;&#039; &lt;br /&gt;
* Uses HS256 algorithm (Mealie&#039;s OIDC library supports it)&lt;br /&gt;
* Self-signed Certificate uses RS256 (rejected by Mealie)&lt;br /&gt;
&lt;br /&gt;
==== Configure Provider: Property Mappings ====&lt;br /&gt;
&lt;br /&gt;
Add OAuth scope property mappings:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
from authentik.core.models import PropertyMapping&lt;br /&gt;
&lt;br /&gt;
p = OAuth2Provider.objects.get(name=&#039;mealie-oauth&#039;)&lt;br /&gt;
email_pm = PropertyMapping.objects.get(name=&#039;authentik default OAuth Mapping: OpenID \\&#039;email\\&#039;&#039;)&lt;br /&gt;
profile_pm = PropertyMapping.objects.get(name=&#039;authentik default OAuth Mapping: OpenID \\&#039;profile\\&#039;&#039;)&lt;br /&gt;
openid_pm = PropertyMapping.objects.get(name=&#039;authentik default OAuth Mapping: OpenID \\&#039;openid\\&#039;&#039;)&lt;br /&gt;
&lt;br /&gt;
p.property_mappings.add(email_pm, profile_pm, openid_pm)&lt;br /&gt;
p.include_claims_in_id_token = True&lt;br /&gt;
p.save()&lt;br /&gt;
print(&#039;Added property mappings and enabled claims in ID token&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Create Application in Authentik ====&lt;br /&gt;
&lt;br /&gt;
# Go to &#039;&#039;&#039;Applications → Applications → Create&#039;&#039;&#039;&lt;br /&gt;
# Fill in:&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Name:** Mealie&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Slug:** mealie&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Provider:** mealie-oauth (select from dropdown)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Authorization flow:** default-authorization-flow&lt;br /&gt;
# &#039;&#039;&#039;Save&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
=== 5. Start Mealie ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/mealie-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Wait ~10 seconds for startup, then check:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs mealie --tail=20&lt;br /&gt;
# Should show: &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
# And: &amp;quot;Enabled: True&amp;quot; under the OIDC section&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 6. Configure VPS Nginx Proxy ===&lt;br /&gt;
&lt;br /&gt;
On VPS, add server block to nginx config (or update if exists):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;nginx&amp;quot;&amp;gt;&lt;br /&gt;
server {&lt;br /&gt;
    listen 443 ssl http2;&lt;br /&gt;
    server_name cook.wilsoz.com;&lt;br /&gt;
&lt;br /&gt;
    ssl_certificate /etc/letsencrypt/live/cook.wilsoz.com/fullchain.pem;&lt;br /&gt;
    ssl_certificate_key /etc/letsencrypt/live/cook.wilsoz.com/privkey.pem;&lt;br /&gt;
&lt;br /&gt;
    location / {&lt;br /&gt;
        proxy_pass http://100.64.207.155:9925;&lt;br /&gt;
        proxy_set_header Host $host;&lt;br /&gt;
        proxy_set_header X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Then:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo systemctl reload nginx&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 7. Create First Admin User (In-App) ===&lt;br /&gt;
&lt;br /&gt;
# Navigate to https://cook.wilsoz.com&lt;br /&gt;
# You&#039;ll see the login page with &amp;quot;Authentik&amp;quot; button&lt;br /&gt;
# If Mealie is brand new with no users, it may show an initial setup screen&lt;br /&gt;
# Use Authentik to log in with an account in the &#039;&#039;&#039;authentik Admins&#039;&#039;&#039; group&lt;br /&gt;
** This account gets Mealie admin role automatically&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Operations ==&lt;br /&gt;
&lt;br /&gt;
=== Start/Stop/Restart ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/mealie-app&lt;br /&gt;
&lt;br /&gt;
# Start&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Stop&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Restart&lt;br /&gt;
docker compose restart mealie&lt;br /&gt;
&lt;br /&gt;
# Full restart (clears cached config)&lt;br /&gt;
docker compose down &amp;amp;&amp;amp; sleep 2 &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View Logs ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Live logs&lt;br /&gt;
docker logs -f mealie&lt;br /&gt;
&lt;br /&gt;
# Last 50 lines&lt;br /&gt;
docker logs mealie --tail=50&lt;br /&gt;
&lt;br /&gt;
# Filter for errors&lt;br /&gt;
docker logs mealie 2&amp;gt;&amp;amp;1 | grep -i &amp;quot;error\|exception&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Filter for OIDC&lt;br /&gt;
docker logs mealie 2&amp;gt;&amp;amp;1 | grep -i &amp;quot;oidc&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Reset Admin Password (Database) ===&lt;br /&gt;
&lt;br /&gt;
If you lose the admin password:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec mealie python3 -c &amp;quot;&lt;br /&gt;
import sqlite3&lt;br /&gt;
import bcrypt&lt;br /&gt;
&lt;br /&gt;
db = sqlite3.connect(&#039;/app/data/mealie.db&#039;)&lt;br /&gt;
cursor = db.cursor()&lt;br /&gt;
&lt;br /&gt;
# Set new password for marcus&lt;br /&gt;
new_password = &#039;TempPassword123456!&#039;&lt;br /&gt;
salt = bcrypt.gensalt(rounds=10)&lt;br /&gt;
hashed = bcrypt.hashpw(new_password.encode(&#039;utf-8&#039;), salt).decode(&#039;utf-8&#039;)&lt;br /&gt;
&lt;br /&gt;
cursor.execute(&#039;UPDATE users SET password = ? WHERE username = ?&#039;, (hashed, &#039;marcus&#039;))&lt;br /&gt;
db.commit()&lt;br /&gt;
print(f&#039;Password reset to: {new_password}&#039;)&lt;br /&gt;
db.close()&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Then log in and change it in Mealie settings.&lt;br /&gt;
&lt;br /&gt;
=== Manage Users &amp;amp; Groups ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Via Authentik:&#039;&#039;&#039;&lt;br /&gt;
* Users must be added to Authentik first&lt;br /&gt;
* Create/manage groups: &#039;&#039;&#039;Directory → Groups&#039;&#039;&#039;&lt;br /&gt;
* Add users to groups for role assignment&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Via Mealie:&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Settings → Users&#039;&#039;&#039; — shows Mealie users (created on first OAuth login)&lt;br /&gt;
* User roles are determined by Authentik group membership:&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;authentik Admins** → Mealie admin&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;family-and-friends** → Mealie user&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Configuration Reference ==&lt;br /&gt;
&lt;br /&gt;
=== Environment Variables (&amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Variable&lt;br /&gt;
! Purpose&lt;br /&gt;
! Example&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;BASE_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
| External URL for Mealie&lt;br /&gt;
| &amp;lt;code&amp;gt;https://cook.wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;ALLOW_SIGNUP&amp;lt;/code&amp;gt;&lt;br /&gt;
| Allow registration without OIDC&lt;br /&gt;
| &amp;lt;code&amp;gt;false&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_AUTH_ENABLED&amp;lt;/code&amp;gt;&lt;br /&gt;
| Enable OAuth login&lt;br /&gt;
| &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_CLIENT_ID&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik provider client ID&lt;br /&gt;
| (from Authentik)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_CLIENT_SECRET&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik provider secret&lt;br /&gt;
| (from Authentik)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_CONFIGURATION_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
| OIDC discovery endpoint&lt;br /&gt;
| &amp;lt;code&amp;gt;https://key.wilsoz.com/application/o/mealie/.well-known/openid-configuration&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_USER_GROUP&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik group for regular users&lt;br /&gt;
| &amp;lt;code&amp;gt;family-and-friends&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_ADMIN_GROUP&amp;lt;/code&amp;gt;&lt;br /&gt;
| Authentik group for admins&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik Admins&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_GROUPS_CLAIM&amp;lt;/code&amp;gt;&lt;br /&gt;
| OIDC claim name for groups&lt;br /&gt;
| &amp;lt;code&amp;gt;groups&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_SIGNUP_ENABLED&amp;lt;/code&amp;gt;&lt;br /&gt;
| Auto-create users on first OIDC login&lt;br /&gt;
| &amp;lt;code&amp;gt;true&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;OIDC_AUTO_REDIRECT&amp;lt;/code&amp;gt;&lt;br /&gt;
| Skip login page, go straight to Authentik&lt;br /&gt;
| &amp;lt;code&amp;gt;false&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;TZ&amp;lt;/code&amp;gt;&lt;br /&gt;
| Timezone&lt;br /&gt;
| &amp;lt;code&amp;gt;America/Chicago&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;PUID&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;PGID&amp;lt;/code&amp;gt;&lt;br /&gt;
| Linux user/group for file permissions&lt;br /&gt;
| &amp;lt;code&amp;gt;1000&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Data &amp;amp; Backups ==&lt;br /&gt;
&lt;br /&gt;
=== Data Location ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
/library/mealie-app/mealie-data:/app/data&lt;br /&gt;
&lt;br /&gt;
Contents:&lt;br /&gt;
- mealie.db              (SQLite database — all recipes, users, settings)&lt;br /&gt;
- recipes/               (recipe files)&lt;br /&gt;
- users/                 (user data)&lt;br /&gt;
- backups/               (exported recipe backups)&lt;br /&gt;
- templates/             (custom templates)&lt;br /&gt;
- groups/                (group/household data)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Backup Procedure ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Backup the entire data volume&lt;br /&gt;
docker run --rm \&lt;br /&gt;
  -v mealie-app_mealie-data:/data \&lt;br /&gt;
  -v /home/mnw/backups:/backup \&lt;br /&gt;
  ubuntu tar czf /backup/mealie-$(date +%Y%m%d).tar.gz /data&lt;br /&gt;
&lt;br /&gt;
# List backups&lt;br /&gt;
ls -lh /home/mnw/backups/mealie-*.tar.gz&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Restore from Backup ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Stop Mealie&lt;br /&gt;
cd /library/mealie-app &amp;amp;&amp;amp; docker compose down&lt;br /&gt;
&lt;br /&gt;
# Restore data&lt;br /&gt;
docker run --rm \&lt;br /&gt;
  -v mealie-app_mealie-data:/data \&lt;br /&gt;
  -v /home/mnw/backups:/backup \&lt;br /&gt;
  ubuntu bash -c &amp;quot;rm -rf /data/* &amp;amp;&amp;amp; tar xzf /backup/mealie-20260513.tar.gz -C /data&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Restart&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Login Issues ===&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;Something went wrong&amp;quot; on OAuth callback ====&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039;&lt;br /&gt;
* Authentik login succeeds, but Mealie shows error when redirected back&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Common causes &amp;amp; fixes:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Error&lt;br /&gt;
! Cause&lt;br /&gt;
! Fix&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;KeyError: &#039;keys&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
| JWKS endpoint returns &amp;lt;code&amp;gt;{}&amp;lt;/code&amp;gt;&lt;br /&gt;
| Assign signing key to OAuth provider (see Setup step 4)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;UnsupportedAlgorithmError: RS256 not allowed&amp;lt;/code&amp;gt;&lt;br /&gt;
| Wrong signing key algorithm&lt;br /&gt;
| Use &amp;quot;authentik Internal JWT Certificate&amp;quot; (HS256), not &amp;quot;Self-signed Certificate&amp;quot; (RS256)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;invalid_client: Client authentication failed&amp;lt;/code&amp;gt;&lt;br /&gt;
| Client secret mismatch&lt;br /&gt;
| Verify &amp;lt;code&amp;gt;OIDC_CLIENT_SECRET&amp;lt;/code&amp;gt; in .env matches Authentik provider&lt;br /&gt;
|-&lt;br /&gt;
| HTTP 500 at &amp;lt;code&amp;gt;/api/auth/oauth/callback&amp;lt;/code&amp;gt;&lt;br /&gt;
| Mealie can&#039;t reach Authentik&#039;s token endpoint&lt;br /&gt;
| Check network connectivity, SSL certificates, DNS&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Debug steps:&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check OIDC config is loaded&lt;br /&gt;
docker logs mealie | grep -i &amp;quot;oidc\|enabled&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Test discovery endpoint&lt;br /&gt;
docker exec mealie curl -s &amp;quot;https://key.wilsoz.com/application/o/mealie/.well-known/openid-configuration&amp;quot; -k | jq .&lt;br /&gt;
&lt;br /&gt;
# Check JWKS endpoint has keys&lt;br /&gt;
docker exec mealie curl -s &amp;quot;https://key.wilsoz.com/application/o/mealie/jwks/&amp;quot; -k | jq &#039;.keys | length&#039;&lt;br /&gt;
&lt;br /&gt;
# View OAuth errors&lt;br /&gt;
docker logs mealie --tail=100 | grep -i &amp;quot;oauth\|error\|exception&amp;quot; | tail -20&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;OIDC not appearing on login page&amp;quot; ====&lt;br /&gt;
&lt;br /&gt;
* Verify &amp;lt;code&amp;gt;OIDC_AUTH_ENABLED=true&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;&lt;br /&gt;
* Restart container: &amp;lt;code&amp;gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&amp;lt;/code&amp;gt;&lt;br /&gt;
* Check logs for startup errors&lt;br /&gt;
&lt;br /&gt;
==== &amp;quot;Redirect URI mismatch&amp;quot; error ====&lt;br /&gt;
&lt;br /&gt;
* Ensure Authentik provider redirect URIs match exactly:&lt;br /&gt;
** &amp;lt;code&amp;gt;https://cook.wilsoz.com/login&amp;lt;/code&amp;gt;&lt;br /&gt;
** &amp;lt;code&amp;gt;https://cook.wilsoz.com/login?direct=1&amp;lt;/code&amp;gt;&lt;br /&gt;
* Verify &amp;lt;code&amp;gt;BASE_URL=https://cook.wilsoz.com&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;&lt;br /&gt;
* No trailing slashes or port numbers&lt;br /&gt;
&lt;br /&gt;
==== User not getting admin role ====&lt;br /&gt;
&lt;br /&gt;
* Confirm user is in &#039;&#039;&#039;authentik Admins&#039;&#039;&#039; group (Directory → Groups)&lt;br /&gt;
* Log out and log back in (roles are assigned at login time)&lt;br /&gt;
* Check Mealie Settings → Users to see assigned role&lt;br /&gt;
&lt;br /&gt;
=== Performance ===&lt;br /&gt;
&lt;br /&gt;
==== Slow startup ====&lt;br /&gt;
&lt;br /&gt;
Mealie can take 30-60 seconds on first start or after upgrade. Check:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs mealie | tail -20&lt;br /&gt;
# Look for &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== High memory usage ====&lt;br /&gt;
&lt;br /&gt;
Default image is fine for small recipe collections. For very large:&lt;br /&gt;
* Monitor: &amp;lt;code&amp;gt;docker stats mealie&amp;lt;/code&amp;gt;&lt;br /&gt;
* Limit: Add &amp;lt;code&amp;gt;mem_limit: 2g&amp;lt;/code&amp;gt; to compose file if needed&lt;br /&gt;
&lt;br /&gt;
=== Network Issues ===&lt;br /&gt;
&lt;br /&gt;
==== Can&#039;t access https://cook.wilsoz.com ====&lt;br /&gt;
&lt;br /&gt;
# Check VPS nginx config (see Setup step 6)&lt;br /&gt;
# Verify SSL cert is valid: &amp;lt;code&amp;gt;curl -I https://cook.wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
# Check Mealie is running: &amp;lt;code&amp;gt;docker ps | grep mealie&amp;lt;/code&amp;gt;&lt;br /&gt;
# Check port: &amp;lt;code&amp;gt;netstat -tlnp | grep 9925&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Mealie can&#039;t reach Authentik (key.wilsoz.com) ====&lt;br /&gt;
&lt;br /&gt;
From Mealie container:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec mealie curl -v https://key.wilsoz.com/api/v3/ -k&lt;br /&gt;
# Should return 401 (not accessible, but shouldn&#039;t timeout)&lt;br /&gt;
&lt;br /&gt;
docker exec mealie ping -c 1 key.wilsoz.com&lt;br /&gt;
# Should succeed&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Security Notes ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;OIDC_CLIENT_SECRET:&#039;&#039;&#039; Store securely in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt;, never commit to git&lt;br /&gt;
* &#039;&#039;&#039;Database:&#039;&#039;&#039; &amp;lt;code&amp;gt;/app/data/mealie.db&amp;lt;/code&amp;gt; contains all user data — protect with filesystem permissions&lt;br /&gt;
* &#039;&#039;&#039;Backups:&#039;&#039;&#039; Backup volume includes full database — encrypt backups if stored externally&lt;br /&gt;
* &#039;&#039;&#039;User signup:&#039;&#039;&#039; &amp;lt;code&amp;gt;ALLOW_SIGNUP=false&amp;lt;/code&amp;gt; means only Authentik users can access (good)&lt;br /&gt;
* &#039;&#039;&#039;Groups:&#039;&#039;&#039; Ensure only trusted users are in &amp;lt;code&amp;gt;authentik Admins&amp;lt;/code&amp;gt; group&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Links &amp;amp; References ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Mealie Docs:&#039;&#039;&#039; https://nightly.mealie.io/&lt;br /&gt;
* &#039;&#039;&#039;Mealie GitHub:&#039;&#039;&#039; https://github.com/mealie-recipes/mealie&lt;br /&gt;
* &#039;&#039;&#039;Authentik OIDC:&#039;&#039;&#039; https://goauthentik.io/docs/providers/oauth2&lt;br /&gt;
* &#039;&#039;&#039;Mealie OIDC Integration Guide:&#039;&#039;&#039; https://integrations.goauthentik.io/documentation/mealie/&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Related Services ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Authentik&#039;&#039;&#039; (auth): &amp;lt;code&amp;gt;/library/authentik-app/&amp;lt;/code&amp;gt; — OAuth provider&lt;br /&gt;
* &#039;&#039;&#039;Nginx&#039;&#039;&#039; (proxy): VPS &amp;lt;code&amp;gt;/etc/nginx/sites-available/&amp;lt;/code&amp;gt; — external access&lt;br /&gt;
* &#039;&#039;&#039;Backups:&#039;&#039;&#039; &amp;lt;code&amp;gt;/home/mnw/backups/&amp;lt;/code&amp;gt; — data snapshots&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/Jellyfin&amp;diff=10</id>
		<title>Services/Jellyfin</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/Jellyfin&amp;diff=10"/>
		<updated>2026-07-05T18:16:19Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/JELLYFIN.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/JELLYFIN.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Jellyfin — Media Server =&lt;br /&gt;
&lt;br /&gt;
Operations, backup, and recovery guide for stronghold&#039;s Jellyfin install.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Criticality:&#039;&#039;&#039; &#039;&#039;&#039;MEDIUM — Multi-user service.&#039;&#039;&#039; Media files themselves are large and not in scope for cloud backup (rerippable from physical media as a last resort). Server state (DB, metadata, plugins, config, subtitles) IS in scope — losing it means rebuilding the library from scratch, redoing all manual metadata fixes, and losing watch history. This is the only stronghold service used by multiple people.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Jellyfin&#039;&#039;&#039; is the Free Software media server. Stronghold runs it as a native systemd service (not Docker) because of the package&#039;s tight integration with the host (ffmpeg, GPU, file permissions).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;What&#039;s special about this install:&#039;&#039;&#039;&lt;br /&gt;
* Native systemd, not containerised. Updates via &amp;lt;code&amp;gt;apt&amp;lt;/code&amp;gt;, not Watchtower.&lt;br /&gt;
* Metadata/cache/transcode paths have been &#039;&#039;&#039;relocated&#039;&#039;&#039; away from &amp;lt;code&amp;gt;/var/lib/jellyfin&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/library/jellyfin-server-app/&amp;lt;/code&amp;gt;. The old paths still exist on disk but are stale (untouched since Oct 2025).&lt;br /&gt;
* ~74G of &amp;lt;code&amp;gt;/var/lib/jellyfin/data&amp;lt;/code&amp;gt; is regenerable cache (trickplay, subtitle extraction, internal backups). Only ~16G is genuinely irreplaceable.&lt;br /&gt;
* Uses SQLite — Jellyfin does not support PostgreSQL/MySQL in any non-experimental form as of 10.11.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Version:&#039;&#039;&#039; &amp;lt;code&amp;gt;10.11.8+ubu2404&amp;lt;/code&amp;gt; (Debian package). Latest is &amp;lt;code&amp;gt;10.11.9&amp;lt;/code&amp;gt; (released 2026-05-21) — pending upgrade; see [Maintenance](#maintenance).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Users:&#039;&#039;&#039; Multi-user — Marcus + family/friends. Auth is local Jellyfin accounts (not Authentik-federated).&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Service Information ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Property&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Package&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;jellyfin&amp;lt;/code&amp;gt; (Debian, Ubuntu 24.04 repo)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Systemd unit&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/usr/lib/systemd/system/jellyfin.service&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Process user&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;jellyfin:jellyfin&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Working directory&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Environment file&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/etc/default/jellyfin&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Binary&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/usr/bin/jellyfin&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HTTP port (internal)&#039;&#039;&#039;&lt;br /&gt;
| 8096&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HTTPS port (internal)&#039;&#039;&#039;&lt;br /&gt;
| 8920 (unused — TLS terminated upstream)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Public URL&#039;&#039;&#039;&lt;br /&gt;
| (whatever your reverse proxy/Tailscale exposes — internal-only by default)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Companion services ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Service&lt;br /&gt;
! Purpose&lt;br /&gt;
! Where&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;logo-cache&amp;lt;/code&amp;gt; (Docker, &amp;lt;code&amp;gt;nginx:alpine&amp;lt;/code&amp;gt;)&lt;br /&gt;
| &#039;&#039;&#039;Jellyfin / IPTV support tool.&#039;&#039;&#039; Static HTTP server for the IPTV feed used by Jellyfin Live TV and the Apple TV IPTV client app. Serves: channel logos (30-day immutable cache) + &amp;lt;code&amp;gt;filtered.m3u&amp;lt;/code&amp;gt; playlist + &amp;lt;code&amp;gt;filtered.xml&amp;lt;/code&amp;gt; EPG (no-cache, must-revalidate — updater rewrites every ~3h via atomic rename). Stood up 2026-05-24.&lt;br /&gt;
| Compose: &amp;lt;code&amp;gt;/library/logo-cache-app/&amp;lt;/code&amp;gt; · Listens: &amp;lt;code&amp;gt;0.0.0.0:8079&amp;lt;/code&amp;gt; → container &amp;lt;code&amp;gt;:80&amp;lt;/code&amp;gt; · Mounts: &amp;lt;code&amp;gt;/raid1/Consolidated/IPTV/logos → /usr/share/nginx/html:ro&amp;lt;/code&amp;gt; (logo files) and &amp;lt;code&amp;gt;/raid1/Consolidated/IPTV → /srv/iptv:ro&amp;lt;/code&amp;gt; (parent dir, exposed via nginx &amp;lt;code&amp;gt;alias&amp;lt;/code&amp;gt; for &amp;lt;code&amp;gt;/filtered.m3u&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;/filtered.xml&amp;lt;/code&amp;gt; only — directory bind, not single-file, so atomic-rename updates are picked up live) · Watchtower: enabled&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Data layout (the important table) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Path&lt;br /&gt;
! Purpose&lt;br /&gt;
! Size&lt;br /&gt;
! Backup?&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/etc/jellyfin/&amp;lt;/code&amp;gt;&lt;br /&gt;
| System config (&amp;lt;code&amp;gt;system.xml&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;network.xml&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;encoding.xml&amp;lt;/code&amp;gt;, users)&lt;br /&gt;
| &amp;lt;1M&lt;br /&gt;
| &#039;&#039;&#039;YES&#039;&#039;&#039; — mounted as &amp;lt;code&amp;gt;/data/jellyfin-etc&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/jellyfin.db&amp;lt;/code&amp;gt;&lt;br /&gt;
| &#039;&#039;&#039;Core library DB&#039;&#039;&#039; (items, users, playstate)&lt;br /&gt;
| 1.3G&lt;br /&gt;
| &#039;&#039;&#039;YES via SQLite &amp;lt;code&amp;gt;.backup&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/dumps/&amp;lt;/code&amp;gt;&#039;&#039;&#039; — live file excluded from Restic&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/playback_reporting.db&amp;lt;/code&amp;gt;&lt;br /&gt;
| Playback history (from plugin)&lt;br /&gt;
| small&lt;br /&gt;
| &#039;&#039;&#039;YES via SQLite &amp;lt;code&amp;gt;.backup&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/dumps/&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/subtitles/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Extracted/cached subs (&amp;lt;code&amp;gt;.srt&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;.ass&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;.sup&amp;lt;/code&amp;gt;)&lt;br /&gt;
| ~40G&lt;br /&gt;
| YES — re-extraction is slow + lossy&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/collections/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;playlists/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;ScheduledTasks/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;livetv/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;attachments/&amp;lt;/code&amp;gt;&lt;br /&gt;
| User-curated state&lt;br /&gt;
| &amp;lt;100M&lt;br /&gt;
| YES&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/plugins/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Installed plugins + their configs&lt;br /&gt;
| 14M&lt;br /&gt;
| YES&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/root/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Library definitions&lt;br /&gt;
| 7M&lt;br /&gt;
| YES&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/metadata/&amp;lt;/code&amp;gt;&lt;br /&gt;
| &#039;&#039;&#039;STALE&#039;&#039;&#039; (legacy, pre-relocation, last write Oct 2025)&lt;br /&gt;
| 14G&lt;br /&gt;
| &#039;&#039;&#039;NO — excluded&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/trickplay/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Generated thumbnails for scrub previews&lt;br /&gt;
| 16G&lt;br /&gt;
| NO — scheduled task regenerates&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/backups/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Jellyfin&#039;s own internal backup plugin output&lt;br /&gt;
| 15G&lt;br /&gt;
| NO — we have our own&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/just-in-case/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Leftover from some operation&lt;br /&gt;
| 2G&lt;br /&gt;
| NO — investigate + delete&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/library.db.old&#039;&#039;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;library.db.bak&#039;&#039;&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;library.db-wal&amp;lt;/code&amp;gt;&lt;br /&gt;
| Pre-10.9 schema consolidation leftovers&lt;br /&gt;
| ~700M&lt;br /&gt;
| NO — safe to delete&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin/data/SQLiteBackups/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Jellyfin&#039;s auto-DB backups (separate from &amp;lt;code&amp;gt;/data/backups&amp;lt;/code&amp;gt;)&lt;br /&gt;
| varies&lt;br /&gt;
| NO — we have our own&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;&amp;lt;code&amp;gt;/library/jellyfin-server-app/metadata/&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
| &#039;&#039;&#039;ACTIVE metadata&#039;&#039;&#039; (images, NFOs, TMDB cache)&lt;br /&gt;
| 28G&lt;br /&gt;
| &#039;&#039;&#039;YES — mounted as &amp;lt;code&amp;gt;/data/jellyfin-server&amp;lt;/code&amp;gt;&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/jellyfin-server-app/cache/&amp;lt;/code&amp;gt;&lt;br /&gt;
| Image transcodes, web cache&lt;br /&gt;
| 739M&lt;br /&gt;
| NO — excluded&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/jellyfin-server-app/transcodes/&amp;lt;/code&amp;gt;&lt;br /&gt;
| On-the-fly transcode working dir&lt;br /&gt;
| 588K&lt;br /&gt;
| NO — excluded&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Confirm relocation in &amp;lt;code&amp;gt;/etc/jellyfin/system.xml&amp;lt;/code&amp;gt;:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;xml&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;CachePath&amp;gt;/library/jellyfin-server-app/cache&amp;lt;/CachePath&amp;gt;&lt;br /&gt;
&amp;lt;MetadataPath&amp;gt;/library/jellyfin-server-app/metadata&amp;lt;/MetadataPath&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Media files (NOT backed up) ===&lt;br /&gt;
* &amp;lt;code&amp;gt;/raid1/Consolidated/Video/Movies/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;/raid1/Consolidated/Video/TV/&amp;lt;/code&amp;gt;, etc. — many TB&lt;br /&gt;
* &#039;&#039;&#039;Strategy:&#039;&#039;&#039; Not backed up to cloud. Reproducible from physical media (rerip) as last resort. External RAID is the eventual local-only backup plan. Treat the library as effort-recoverable, not byte-recoverable.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Backup ==&lt;br /&gt;
&lt;br /&gt;
=== The cardinal rule ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Never raw-copy a live SQLite WAL DB.&#039;&#039;&#039; A &amp;lt;code&amp;gt;cp jellyfin.db&amp;lt;/code&amp;gt; (or unmodified file backup) while the server is running can yield an inconsistent file that fails to open, or worse, looks fine but is silently corrupt. SQLite&#039;s &amp;lt;code&amp;gt;.backup&amp;lt;/code&amp;gt; command is the only safe online snapshot mechanism.&lt;br /&gt;
&lt;br /&gt;
=== How it works on stronghold ===&lt;br /&gt;
&lt;br /&gt;
Two things conspire to make Jellyfin backup safe:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;host-dump-databases.sh&amp;lt;/code&amp;gt;&#039;&#039;&#039; runs as &amp;lt;code&amp;gt;ExecStartPre&amp;lt;/code&amp;gt; for &amp;lt;code&amp;gt;backup.service&amp;lt;/code&amp;gt; (03:00 CST daily). For each SQLite DB it runs:&lt;br /&gt;
   ```sh&lt;br /&gt;
   sqlite3 /var/lib/jellyfin/data/jellyfin.db &amp;quot;.backup &#039;$DUMP_DIR/jellyfin-$DATE.db&#039;&amp;quot;&lt;br /&gt;
   gzip -f &amp;quot;$DUMP_DIR/jellyfin-$DATE.db&amp;quot;&lt;br /&gt;
   ```&lt;br /&gt;
   The &amp;lt;code&amp;gt;.backup&amp;lt;/code&amp;gt; command takes a short shared lock, copies pages atomically, and produces a file that&#039;s identical to a &amp;lt;code&amp;gt;VACUUM INTO&amp;lt;/code&amp;gt;-style snapshot. Jellyfin keeps running throughout.&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;backup-to-hetzner.sh&amp;lt;/code&amp;gt;&#039;&#039;&#039; then runs Restic. The live &amp;lt;code&amp;gt;jellyfin.db&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;playback_reporting.db&amp;lt;/code&amp;gt;, and all their &amp;lt;code&amp;gt;-wal&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;-shm&amp;lt;/code&amp;gt; sidecars are &#039;&#039;&#039;excluded&#039;&#039;&#039; so the only DB copy in the snapshot is the consistent one from &amp;lt;code&amp;gt;/dumps/&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
Restic also excludes the regenerable dirs (trickplay, internal backups, just-in-case, stale metadata path, cache, transcodes).&lt;br /&gt;
&lt;br /&gt;
=== What&#039;s actually in a snapshot ===&lt;br /&gt;
&lt;br /&gt;
Per nightly Restic run for Jellyfin:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Source mount&lt;br /&gt;
! Inside snapshot&lt;br /&gt;
! Approx after exclusions&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/etc/jellyfin&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/data/jellyfin-etc&amp;lt;/code&amp;gt;&lt;br /&gt;
| All config XML&lt;br /&gt;
| &amp;lt;1M&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/var/lib/jellyfin&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/data/jellyfin&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;plugins/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;root/&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;data/{subtitles,collections,playlists,ScheduledTasks,livetv,attachments}&amp;lt;/code&amp;gt;&lt;br /&gt;
| ~40G (subtitles dominate)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/jellyfin-server-app&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/data/jellyfin-server&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;metadata/&amp;lt;/code&amp;gt;&lt;br /&gt;
| 28G&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/backup-stack/dumps/&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/dumps&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;jellyfin-YYYYMMDD.db.gz&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;jellyfin-playback-YYYYMMDD.db.gz&amp;lt;/code&amp;gt;&lt;br /&gt;
| ~300M compressed&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
First snapshot ≈ 70G stored. Daily incrementals tiny thanks to Restic dedup/compression.&lt;br /&gt;
&lt;br /&gt;
=== Verifying the backup ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Confirm today&#039;s SQLite dump was created&lt;br /&gt;
ls -lh /library/backup-stack/dumps/jellyfin-*.db.gz&lt;br /&gt;
&lt;br /&gt;
# Spot-check dump integrity (extract + open)&lt;br /&gt;
gunzip -k /library/backup-stack/dumps/jellyfin-$(date +%Y%m%d).db.gz&lt;br /&gt;
sqlite3 /tmp/jellyfin-test.db &amp;quot;PRAGMA integrity_check;&amp;quot;  # expect &amp;quot;ok&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Confirm Restic snapshot contains the right paths&lt;br /&gt;
docker exec restic-backup restic snapshots --latest 1&lt;br /&gt;
docker exec restic-backup restic ls latest | grep -E &amp;quot;^/data/jellyfin-(etc|server)?&amp;quot; | head -20&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Recovery ==&lt;br /&gt;
&lt;br /&gt;
=== Scenario A: corrupt or lost &amp;lt;code&amp;gt;jellyfin.db&amp;lt;/code&amp;gt; ===&lt;br /&gt;
&lt;br /&gt;
The DB is the most fragile and most critical piece. Recovery in order of cost:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Use the live &amp;lt;code&amp;gt;data/SQLiteBackups/&amp;lt;/code&amp;gt;&#039;&#039;&#039; if it&#039;s recent and intact — Jellyfin keeps its own rolling SQLite copies here.&lt;br /&gt;
# &#039;&#039;&#039;Restore from &amp;lt;code&amp;gt;/library/backup-stack/dumps/jellyfin-YYYYMMDD.db.gz&amp;lt;/code&amp;gt;&#039;&#039;&#039; (today&#039;s dump, lives locally on stronghold).&lt;br /&gt;
# &#039;&#039;&#039;Restore from Restic&#039;&#039;&#039; (Hetzner) if local dumps are gone too.&lt;br /&gt;
&lt;br /&gt;
Procedure (option 2 or 3):&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo systemctl stop jellyfin&lt;br /&gt;
&lt;br /&gt;
# Move aside the broken DB&lt;br /&gt;
sudo mv /var/lib/jellyfin/data/jellyfin.db{,.broken-$(date +%s)}&lt;br /&gt;
sudo rm -f /var/lib/jellyfin/data/jellyfin.db-{wal,shm}&lt;br /&gt;
&lt;br /&gt;
# From local dump:&lt;br /&gt;
sudo gunzip -c /library/backup-stack/dumps/jellyfin-YYYYMMDD.db.gz \&lt;br /&gt;
  | sudo tee /var/lib/jellyfin/data/jellyfin.db &amp;gt;/dev/null&lt;br /&gt;
sudo chown jellyfin:jellyfin /var/lib/jellyfin/data/jellyfin.db&lt;br /&gt;
&lt;br /&gt;
# OR from Restic:&lt;br /&gt;
docker exec restic-backup restic restore latest \&lt;br /&gt;
  --target /tmp/jellyfin-restore \&lt;br /&gt;
  --include /dumps/jellyfin-YYYYMMDD.db.gz&lt;br /&gt;
sudo gunzip -c /tmp/jellyfin-restore/dumps/jellyfin-*.db.gz \&lt;br /&gt;
  | sudo tee /var/lib/jellyfin/data/jellyfin.db &amp;gt;/dev/null&lt;br /&gt;
sudo chown jellyfin:jellyfin /var/lib/jellyfin/data/jellyfin.db&lt;br /&gt;
&lt;br /&gt;
sudo systemctl start jellyfin&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scenario B: full server state loss ===&lt;br /&gt;
&lt;br /&gt;
Rebuild order:&lt;br /&gt;
# Install &amp;lt;code&amp;gt;jellyfin&amp;lt;/code&amp;gt; package, &#039;&#039;&#039;do not start&#039;&#039;&#039; the service yet.&lt;br /&gt;
# Restic restore &amp;lt;code&amp;gt;/data/jellyfin-etc/&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/etc/jellyfin/&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Restic restore &amp;lt;code&amp;gt;/data/jellyfin/&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/var/lib/jellyfin/&amp;lt;/code&amp;gt; (minus the excluded paths — they&#039;ll regenerate).&lt;br /&gt;
# Restic restore &amp;lt;code&amp;gt;/data/jellyfin-server/&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;/library/jellyfin-server-app/&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Restore &amp;lt;code&amp;gt;jellyfin.db&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;playback_reporting.db&amp;lt;/code&amp;gt; from &amp;lt;code&amp;gt;/dumps/&amp;lt;/code&amp;gt; per Scenario A.&lt;br /&gt;
# &amp;lt;code&amp;gt;chown -R jellyfin:jellyfin /var/lib/jellyfin /library/jellyfin-server-app&amp;lt;/code&amp;gt;.&lt;br /&gt;
# &amp;lt;code&amp;gt;systemctl start jellyfin&amp;lt;/code&amp;gt;. First start will regenerate trickplay, transcode dirs, etc.&lt;br /&gt;
# Re-scan libraries from web UI (idempotent — won&#039;t re-import existing items).&lt;br /&gt;
&lt;br /&gt;
=== Scenario C: media library on /raid1 is gone ===&lt;br /&gt;
&lt;br /&gt;
Server state survives independently. Restore server state per Scenario B; then:&lt;br /&gt;
* Media has to come back from physical media (rerip) or external RAID if one exists.&lt;br /&gt;
* The library DB still references the original paths, so once the files reappear at the same paths, items light back up. No re-import needed.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Health checks ==&lt;br /&gt;
&lt;br /&gt;
Add as a weekly cron / systemd timer (see [TODO](#known-issues--tracked-work)):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sqlite3 /var/lib/jellyfin/data/jellyfin.db &amp;quot;PRAGMA integrity_check;&amp;quot;&lt;br /&gt;
sqlite3 /var/lib/jellyfin/data/playback_reporting.db &amp;quot;PRAGMA integrity_check;&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Any output other than &amp;lt;code&amp;gt;ok&amp;lt;/code&amp;gt; → page Matrix.&lt;br /&gt;
&lt;br /&gt;
Also useful, opportunistically:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# What&#039;s holding the DB right now (should be ONLY the jellyfin process)&lt;br /&gt;
sudo lsof /var/lib/jellyfin/data/jellyfin.db&lt;br /&gt;
&lt;br /&gt;
# Confirm WAL is on (it should always say &amp;quot;wal&amp;quot;)&lt;br /&gt;
sudo sqlite3 /var/lib/jellyfin/data/jellyfin.db &amp;quot;PRAGMA journal_mode;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Lock-error counts in the last day&lt;br /&gt;
sudo grep -c &amp;quot;database table is locked&amp;quot; /var/log/jellyfin/log_$(date +%Y%m%d).log&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Maintenance ==&lt;br /&gt;
&lt;br /&gt;
=== Apt update path ===&lt;br /&gt;
Jellyfin updates via the Ubuntu apt repo, &#039;&#039;&#039;not&#039;&#039;&#039; Watchtower (it&#039;s not a container).&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Check current vs latest&lt;br /&gt;
apt list --installed 2&amp;gt;/dev/null | grep jellyfin&lt;br /&gt;
# Latest tag: https://api.github.com/repos/jellyfin/jellyfin/releases/latest&lt;br /&gt;
&lt;br /&gt;
# Upgrade pattern (always snapshot DB first):&lt;br /&gt;
sudo systemctl stop jellyfin&lt;br /&gt;
sudo sqlite3 /var/lib/jellyfin/data/jellyfin.db &amp;quot;.backup &#039;/library/backup-stack/dumps/jellyfin-pre-upgrade-$(date +%Y%m%d).db&#039;&amp;quot;&lt;br /&gt;
sudo apt update &amp;amp;&amp;amp; sudo apt install --only-upgrade jellyfin jellyfin-server jellyfin-web&lt;br /&gt;
sudo systemctl start jellyfin&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Scheduled tasks worth knowing about ===&lt;br /&gt;
Visible in the web UI at &amp;lt;code&amp;gt;Dashboard → Scheduled Tasks&amp;lt;/code&amp;gt;. Two that matter:&lt;br /&gt;
* &#039;&#039;&#039;Extract Subtitles&#039;&#039;&#039; — runs daily, can cause &amp;lt;code&amp;gt;UserData&amp;lt;/code&amp;gt; lock contention if it overlaps active playback (see [Gotchas](#gotchas)). Schedule defensively (e.g. 04:30, not 01:00).&lt;br /&gt;
* &#039;&#039;&#039;Refresh Library&#039;&#039;&#039; — keeps &amp;lt;code&amp;gt;jellyfin.db&amp;lt;/code&amp;gt; warm. Don&#039;t run multiple library refreshes in parallel.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Gotchas ==&lt;br /&gt;
&lt;br /&gt;
=== SQLite &amp;quot;database table is locked&amp;quot; errors are internal, not external ===&lt;br /&gt;
&lt;br /&gt;
If you see &amp;lt;code&amp;gt;SQLite Error 6: &#039;database table is locked: UserData&#039;&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;/var/log/jellyfin/log_*.log&amp;lt;/code&amp;gt;:&lt;br /&gt;
* Error code &#039;&#039;&#039;6 is &amp;lt;code&amp;gt;SQLITE_LOCKED&amp;lt;/code&amp;gt;&#039;&#039;&#039; — same-process shared-cache contention. NOT another OS process holding the file. So it is &#039;&#039;&#039;not&#039;&#039;&#039; a backup/cp problem.&lt;br /&gt;
* Code 5 (&amp;lt;code&amp;gt;SQLITE_BUSY&amp;lt;/code&amp;gt;) would indicate external process contention. We don&#039;t see that.&lt;br /&gt;
* Typical cause: &#039;&#039;&#039;PlaybackReporting plugin&#039;&#039;&#039; is in a tight write loop on &amp;lt;code&amp;gt;UserData&amp;lt;/code&amp;gt; for an active session, and &#039;&#039;&#039;Extract Subtitles&#039;&#039;&#039; scheduled task fires at the same time and tries to write the same table.&lt;br /&gt;
* Mitigations:&lt;br /&gt;
## Move &amp;lt;code&amp;gt;Extract Subtitles&amp;lt;/code&amp;gt; schedule off active-viewing hours.&lt;br /&gt;
## Test disabling PlaybackReporting plugin (you lose history but isolate the variable).&lt;br /&gt;
## Upgrade — 10.11.9 (PR #15368 + #16845) explicitly touches UserManager EF Core + user session concurrency logging.&lt;br /&gt;
&lt;br /&gt;
These errors abort the failing transaction cleanly. They do &#039;&#039;&#039;not&#039;&#039;&#039; corrupt the DB.&lt;br /&gt;
&lt;br /&gt;
=== Metadata path relocation ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;system.xml&amp;lt;/code&amp;gt; points &amp;lt;code&amp;gt;MetadataPath&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;CachePath&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;/library/jellyfin-server-app/&amp;lt;/code&amp;gt; — not the package default of &amp;lt;code&amp;gt;/var/lib/jellyfin/&amp;lt;/code&amp;gt;. The default-location dirs still exist with stale content from before relocation. &#039;&#039;&#039;Don&#039;t be fooled&#039;&#039;&#039; into thinking &amp;lt;code&amp;gt;/var/lib/jellyfin/metadata&amp;lt;/code&amp;gt; is the live one; check &amp;lt;code&amp;gt;system.xml&amp;lt;/code&amp;gt; to confirm. Both dirs are listed in the data-layout table above with their actual roles.&lt;br /&gt;
&lt;br /&gt;
=== The &amp;lt;code&amp;gt;.sup&amp;lt;/code&amp;gt; subtitle files ===&lt;br /&gt;
&lt;br /&gt;
PGS (bitmap) subs from Blu-rays. Most are 5–30MB, but some legitimately exceed 100MB or 1GB — e.g. &#039;&#039;&#039;animated sing-along subtitles&#039;&#039;&#039; (Wicked&#039;s &amp;lt;code&amp;gt;.sup&amp;lt;/code&amp;gt; is 1.1GB and intentional). &#039;&#039;&#039;Don&#039;t auto-delete by size.&#039;&#039;&#039; Look up the title via &amp;lt;code&amp;gt;BaseItems.Id&amp;lt;/code&amp;gt; matching the dir hash (dashed-uppercase GUID) before deleting any oversized &amp;lt;code&amp;gt;.sup&amp;lt;/code&amp;gt;. Genuine broken extractions can go; Jellyfin re-extracts on demand.&lt;br /&gt;
&lt;br /&gt;
=== DB lookups ===&lt;br /&gt;
The on-disk subtitle dir name like &amp;lt;code&amp;gt;da/dab167ae-31c1-aa0a-7b64-dd821c44b879/4.sup&amp;lt;/code&amp;gt; maps to a &amp;lt;code&amp;gt;BaseItems.Id&amp;lt;/code&amp;gt;. The DB stores it dashed and uppercase:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo sqlite3 /var/lib/jellyfin/data/jellyfin.db \&lt;br /&gt;
  &amp;quot;SELECT Name, Type, Path FROM BaseItems WHERE Id=&#039;DAB167AE-31C1-AA0A-7B64-DD821C44B879&#039;;&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Jellyfin&#039;s own backup plugin ===&lt;br /&gt;
&lt;br /&gt;
There&#039;s a built-in Backup plugin that writes to &amp;lt;code&amp;gt;data/backups/&amp;lt;/code&amp;gt;. We &#039;&#039;&#039;don&#039;t&#039;&#039;&#039; rely on it — our own &amp;lt;code&amp;gt;.backup&amp;lt;/code&amp;gt; dumps are simpler to reason about. The &amp;lt;code&amp;gt;data/backups/&amp;lt;/code&amp;gt; dir is excluded from Restic for that reason. If you ever want to use the built-in plugin instead, flip the exclusion and update this doc.&lt;br /&gt;
&lt;br /&gt;
=== Stale &amp;lt;code&amp;gt;library.db.*&amp;lt;/code&amp;gt; files ===&lt;br /&gt;
&lt;br /&gt;
Jellyfin 10.9 consolidated multiple SQLite DBs into a single &amp;lt;code&amp;gt;jellyfin.db&amp;lt;/code&amp;gt;. The leftover &amp;lt;code&amp;gt;library.db.old&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;library.db.bak1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;library.db-wal&amp;lt;/code&amp;gt; (~700M total) under &amp;lt;code&amp;gt;/var/lib/jellyfin/data/&amp;lt;/code&amp;gt; are safe to delete after confirming Jellyfin starts cleanly without them — Jellyfin will not reach for them again.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Known issues / tracked work ==&lt;br /&gt;
&lt;br /&gt;
See &amp;lt;code&amp;gt;TODO.md&amp;lt;/code&amp;gt; § Jellyfin for the live list. Current outstanding items:&lt;br /&gt;
* [ ] Investigate + delete oversized &amp;lt;code&amp;gt;.sup&amp;lt;/code&amp;gt; files that are genuinely broken (size alone isn&#039;t a signal; manual eyeball required).&lt;br /&gt;
* [ ] Delete pre-10.9 schema cruft (&amp;lt;code&amp;gt;library.db.old&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;library.db.bak1&amp;lt;/code&amp;gt;, etc.).&lt;br /&gt;
* [ ] Weekly &amp;lt;code&amp;gt;PRAGMA integrity_check&amp;lt;/code&amp;gt; → Matrix alert on anything other than &amp;lt;code&amp;gt;ok&amp;lt;/code&amp;gt;.&lt;br /&gt;
* [ ] Upgrade to 10.11.9 (UserManager/EF Core fixes; minor, do alongside next routine maintenance window).&lt;br /&gt;
* [ ] Haruhi S01E01 has a nightly subtitle-extraction failure (&amp;lt;code&amp;gt;Unable to get streams for File:...&amp;lt;/code&amp;gt;) — investigate file integrity.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== References ==&lt;br /&gt;
&lt;br /&gt;
* Backup stack overview: &amp;lt;code&amp;gt;services/BACKUP_SYSTEM.md&amp;lt;/code&amp;gt;&lt;br /&gt;
* Restic include/exclude patterns: &amp;lt;code&amp;gt;/library/backup-stack/scripts/backup-to-hetzner.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
* SQLite dump invocations: &amp;lt;code&amp;gt;/library/backup-stack/scripts/host-dump-databases.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
* Disaster recovery procedure: &amp;lt;code&amp;gt;DISASTER_RECOVERY.md&amp;lt;/code&amp;gt; (this service&#039;s recovery section above is the authoritative copy)&lt;br /&gt;
* Upstream changelog: https://github.com/jellyfin/jellyfin/releases&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/OpenBao&amp;diff=9</id>
		<title>Services/OpenBao</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/OpenBao&amp;diff=9"/>
		<updated>2026-07-05T18:16:03Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/OPENBAO.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/OPENBAO.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= OpenBao Secret Store =&lt;br /&gt;
&lt;br /&gt;
Self-hosted Vault-API-compatible secret store. The canonical record for credentials used by Claude Code (MCP), shell scripts, and n8n workflows.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Vaultwarden remains the human-facing store.&#039;&#039;&#039; OpenBao is the machine-facing one. They mirror each other; on rotation, update both.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Container &amp;amp; Files ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| Container&lt;br /&gt;
| &amp;lt;code&amp;gt;openbao&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Compose&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Config&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/config/openbao.hcl&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Storage&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/data/&amp;lt;/code&amp;gt; (Raft)&lt;br /&gt;
|-&lt;br /&gt;
| Init output&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/.init-output.json&amp;lt;/code&amp;gt; (chmod 600 — root token + unseal key)&lt;br /&gt;
|-&lt;br /&gt;
| Scripts token&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/.scripts-token&amp;lt;/code&amp;gt; (chmod 600 — used by &amp;lt;code&amp;gt;bao-get.sh&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| Claude token&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/.claude-token&amp;lt;/code&amp;gt; (chmod 600 — also embedded in &amp;lt;code&amp;gt;~/.claude.json&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| Auto-unseal&lt;br /&gt;
| &amp;lt;code&amp;gt;/usr/local/bin/openbao-unseal.sh&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;openbao-unseal.service&amp;lt;/code&amp;gt; (systemd)&lt;br /&gt;
|-&lt;br /&gt;
| Helper script&lt;br /&gt;
| &amp;lt;code&amp;gt;/usr/local/bin/bao-get.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| n8n cred sync&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/openbao-app/sync-n8n-creds.py&amp;lt;/code&amp;gt; (timer: &amp;lt;code&amp;gt;sync-n8n-creds.timer&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| MCP binary&lt;br /&gt;
| &amp;lt;code&amp;gt;/usr/local/bin/vault-mcp-server&amp;lt;/code&amp;gt; (HashiCorp v0.2.0)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;VC sources&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;scripts/openbao/&amp;lt;/code&amp;gt; in this repo — see &amp;lt;code&amp;gt;scripts/openbao/README.md&amp;lt;/code&amp;gt; for deploy&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/openbao-app &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
docker logs --tail=50 openbao 2&amp;gt;&amp;amp;1 | grep -v &amp;quot;^chown\|Could not chown&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Ports &amp;amp; Endpoints ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Port&lt;br /&gt;
! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| 8200&lt;br /&gt;
| API + UI (HTTP, no TLS — internal only)&lt;br /&gt;
|-&lt;br /&gt;
| 8201&lt;br /&gt;
| Cluster (Raft consensus, single-node here)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Endpoint&lt;br /&gt;
! URL&lt;br /&gt;
|-&lt;br /&gt;
| API (host)&lt;br /&gt;
| &amp;lt;code&amp;gt;http://localhost:8200&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| API (LAN)&lt;br /&gt;
| &amp;lt;code&amp;gt;http://192.168.0.109:8200&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| API (Tailscale)&lt;br /&gt;
| &amp;lt;code&amp;gt;http://100.64.207.155:8200&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| API (n8n container)&lt;br /&gt;
| &amp;lt;code&amp;gt;http://172.29.0.1:8200&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Web UI&lt;br /&gt;
| &amp;lt;code&amp;gt;http://192.168.0.109:8200/ui&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Not exposed publicly&#039;&#039;&#039; — no VPS nginx route, intentionally. UI is LAN/Tailscale only.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Credentials ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;All in Vaultwarden → &amp;quot;Stronghold Infrastructure&amp;quot; folder.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Vaultwarden item&lt;br /&gt;
! Contents&lt;br /&gt;
|-&lt;br /&gt;
| OpenBao Unseal&lt;br /&gt;
| root token (&amp;lt;code&amp;gt;s.TqAL...&amp;lt;/code&amp;gt;) + unseal key (&amp;lt;code&amp;gt;dHIJ...=&amp;lt;/code&amp;gt;) in notes&lt;br /&gt;
|-&lt;br /&gt;
| OpenBao Tokens&lt;br /&gt;
| claude-mcp token (login.password) + scripts-n8n token (custom field)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
The init-output.json on stronghold has the same data — that&#039;s how &amp;lt;code&amp;gt;openbao-unseal.service&amp;lt;/code&amp;gt; reads the unseal key at boot.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Reading Secrets ==&lt;br /&gt;
&lt;br /&gt;
=== From Claude Code (MCP) ===&lt;br /&gt;
&lt;br /&gt;
Just ask: &#039;&#039;&amp;quot;fetch secret/ntfy from vault&amp;quot;&#039;&#039; or &#039;&#039;&amp;quot;what&#039;s the mxroute api-key?&amp;quot;&#039;&#039; — the &amp;lt;code&amp;gt;vault&amp;lt;/code&amp;gt; MCP server (16 tools) handles it.&lt;br /&gt;
&lt;br /&gt;
=== From shell scripts ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
bao-get.sh PATH FIELD&lt;br /&gt;
# e.g.&lt;br /&gt;
bao-get.sh ntfy token&lt;br /&gt;
bao-get.sh mxroute api-key&lt;br /&gt;
bao-get.sh authentik api-token&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Reads from &amp;lt;code&amp;gt;/library/openbao-app/.scripts-token&amp;lt;/code&amp;gt;. Outputs the field value to stdout, no newline.&lt;br /&gt;
&lt;br /&gt;
=== From curl (any host with LAN access) ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
TOKEN=$(cat /library/openbao-app/.scripts-token)&lt;br /&gt;
curl -sf http://localhost:8200/v1/secret/data/PATH \&lt;br /&gt;
  -H &amp;quot;X-Vault-Token: $TOKEN&amp;quot; \&lt;br /&gt;
  | jq -r &#039;.data.data.FIELD&#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== From n8n ===&lt;br /&gt;
&lt;br /&gt;
HTTP Request node:&lt;br /&gt;
* Method: &amp;lt;code&amp;gt;GET&amp;lt;/code&amp;gt;&lt;br /&gt;
* URL: &amp;lt;code&amp;gt;http://172.29.0.1:8200/v1/secret/data/PATH&amp;lt;/code&amp;gt;&lt;br /&gt;
* Headers: &amp;lt;code&amp;gt;X-Vault-Token: &amp;lt;scripts-n8n token&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* Response field: &amp;lt;code&amp;gt;data.data.&amp;lt;field&amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Or in a Code node:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;javascript&amp;quot;&amp;gt;&lt;br /&gt;
const resp = await this.helpers.httpRequest({&lt;br /&gt;
  method: &#039;GET&#039;,&lt;br /&gt;
  url: &#039;http://172.29.0.1:8200/v1/secret/data/mxroute&#039;,&lt;br /&gt;
  headers: { &#039;X-Vault-Token&#039;: &#039;&amp;lt;scripts-n8n token&amp;gt;&#039; },&lt;br /&gt;
});&lt;br /&gt;
const apiKey = resp.data.data[&#039;api-key&#039;];&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Recommended:&#039;&#039;&#039; store the scripts-n8n token as an &amp;lt;code&amp;gt;httpHeaderAuth&amp;lt;/code&amp;gt; credential in n8n (header name &amp;lt;code&amp;gt;X-Vault-Token&amp;lt;/code&amp;gt;) so workflows reference it by credential ID.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Writing Secrets (via root token) ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)&lt;br /&gt;
&lt;br /&gt;
# kv put REPLACES the entire record — pass all fields you want kept&lt;br /&gt;
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=&amp;quot;$ROOT_TOKEN&amp;quot; openbao \&lt;br /&gt;
  bao kv put secret/PATH field1=value1 field2=value2&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
For multiline values (private keys, certs), use the API directly:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
python3 - &amp;lt;&amp;lt; &#039;PY&#039;&lt;br /&gt;
import json, urllib.request&lt;br /&gt;
payload = {&amp;quot;data&amp;quot;: {&amp;quot;private-key&amp;quot;: open(&#039;/path/to/key&#039;).read()}}&lt;br /&gt;
req = urllib.request.Request(&lt;br /&gt;
  &amp;quot;http://localhost:8200/v1/secret/data/PATH&amp;quot;,&lt;br /&gt;
  method=&amp;quot;POST&amp;quot;, data=json.dumps(payload).encode(),&lt;br /&gt;
  headers={&amp;quot;X-Vault-Token&amp;quot;: &amp;quot;&amp;lt;ROOT_TOKEN&amp;gt;&amp;quot;, &amp;quot;Content-Type&amp;quot;: &amp;quot;application/json&amp;quot;})&lt;br /&gt;
print(urllib.request.urlopen(req).read())&lt;br /&gt;
PY&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Secret Inventory ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Path&lt;br /&gt;
! Fields&lt;br /&gt;
! Source-of-truth status&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/n8n&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;api-key&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical; n8n&#039;s own auth is separate&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/mxroute&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;server&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;api-key&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/homeassistant&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;token&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;host&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;port&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical; mirrored in n8n credential &amp;lt;code&amp;gt;R9LGxX...&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;4RowxM...&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/ntfy&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;token&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical; mirrored in n8n credential &amp;lt;code&amp;gt;w0gXpM...&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/authentik&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;api-token&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/matrix&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;zordon-token&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical; mirrored in n8n credential &amp;lt;code&amp;gt;x5tv5a...&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/stalwart&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;admin-password&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/immich&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;admin-api-key&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;user-api-key&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/bitwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;client-id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;client-secret&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;master-password&amp;lt;/code&amp;gt;&lt;br /&gt;
| For &amp;lt;code&amp;gt;bw&amp;lt;/code&amp;gt; CLI; also in &amp;lt;code&amp;gt;~/.config/claude/vaultwarden.env&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/openai&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;api-key&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;bearer-header&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical; mirrored in n8n credentials &amp;lt;code&amp;gt;NIQrbn...&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;XB0V2U...&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/anthropic&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;api-key&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical; mirrored in n8n credential &amp;lt;code&amp;gt;bEPlqP...&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/audiobookshelf&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;bearer-header&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical; mirrored in n8n credential &amp;lt;code&amp;gt;5MCzns...&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;secret/ssh-stronghold&amp;lt;/code&amp;gt;&lt;br /&gt;
| &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;host&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;port&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;private-key&amp;lt;/code&amp;gt;&lt;br /&gt;
| OpenBao canonical; mirrored in n8n credential &amp;lt;code&amp;gt;2ugS1i...&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
To list paths live: &amp;lt;code&amp;gt;bao-get.sh&amp;lt;/code&amp;gt; doesn&#039;t support list — use the UI or:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)&lt;br /&gt;
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=&amp;quot;$ROOT_TOKEN&amp;quot; openbao bao kv list secret/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Operational ==&lt;br /&gt;
&lt;br /&gt;
=== Restart ===&lt;br /&gt;
&lt;br /&gt;
OpenBao re-seals on every restart. The systemd service handles auto-unseal:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/openbao-app &amp;amp;&amp;amp; docker compose restart&lt;br /&gt;
sudo systemctl start openbao-unseal.service&lt;br /&gt;
docker exec -e BAO_ADDR=http://127.0.0.1:8200 openbao bao status | grep Sealed&lt;br /&gt;
# Expected: Sealed: false&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Manual unseal (if the systemd unit fails):&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
/usr/local/bin/openbao-unseal.sh&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Backups ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;/library/openbao-app/&amp;lt;/code&amp;gt; is mounted into &amp;lt;code&amp;gt;restic-backup&amp;lt;/code&amp;gt; as &amp;lt;code&amp;gt;/data/openbao:ro&amp;lt;/code&amp;gt; and included in the nightly Hetzner restic backup. Both the Raft data &#039;&#039;&#039;and&#039;&#039;&#039; &amp;lt;code&amp;gt;.init-output.json&amp;lt;/code&amp;gt; are backed up — restoring requires both (the unseal key would be lost otherwise).&lt;br /&gt;
&lt;br /&gt;
=== n8n credential sync ===&lt;br /&gt;
&lt;br /&gt;
n8n credentials are mirrored from OpenBao by &amp;lt;code&amp;gt;/library/openbao-app/sync-n8n-creds.py&amp;lt;/code&amp;gt;. The script reads each managed credential&#039;s spec (n8n ID + OpenBao path + field mapping) from a &amp;lt;code&amp;gt;MAPPINGS&amp;lt;/code&amp;gt; table, diffs against &amp;lt;code&amp;gt;n8n export:credentials --decrypted&amp;lt;/code&amp;gt;, and runs &amp;lt;code&amp;gt;n8n import:credentials&amp;lt;/code&amp;gt; only on drift. &#039;&#039;&#039;Match is by credential ID — never creates new credentials.&#039;&#039;&#039; Posts to &amp;lt;code&amp;gt;#notifications:wilsoz.com&amp;lt;/code&amp;gt; on change.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
/library/openbao-app/sync-n8n-creds.py --dry-run          # preview drift&lt;br /&gt;
/library/openbao-app/sync-n8n-creds.py                    # sync all&lt;br /&gt;
/library/openbao-app/sync-n8n-creds.py --only ntfy_auth   # one mapping&lt;br /&gt;
/library/openbao-app/sync-n8n-creds.py --quiet            # cron-friendly&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Daily timer: &amp;lt;code&amp;gt;sync-n8n-creds.timer&amp;lt;/code&amp;gt; (04:30 + 0–5 min jitter). Logs in &amp;lt;code&amp;gt;journalctl -u sync-n8n-creds.service&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Rotation flow:&#039;&#039;&#039; update OpenBao → next nightly sync (or manual run) propagates to n8n. No workflow edits.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Adding a new mapping:&#039;&#039;&#039; append to &amp;lt;code&amp;gt;MAPPINGS&amp;lt;/code&amp;gt; in the script. Must specify &amp;lt;code&amp;gt;n8n_id&amp;lt;/code&amp;gt; (existing credential — create the credential in n8n UI first), &amp;lt;code&amp;gt;n8n_name&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;n8n_type&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;bao_path&amp;lt;/code&amp;gt;, and a &amp;lt;code&amp;gt;build&amp;lt;/code&amp;gt; lambda that returns the n8n &amp;lt;code&amp;gt;data&amp;lt;/code&amp;gt; dict from the OpenBao field dict.&lt;br /&gt;
&lt;br /&gt;
=== Token rotation ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)&lt;br /&gt;
# Revoke old token&lt;br /&gt;
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=&amp;quot;$ROOT_TOKEN&amp;quot; openbao \&lt;br /&gt;
  bao token revoke &amp;lt;OLD_TOKEN&amp;gt;&lt;br /&gt;
# Mint new with same policy&lt;br /&gt;
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=&amp;quot;$ROOT_TOKEN&amp;quot; openbao \&lt;br /&gt;
  bao token create -policy=readonly-secrets -no-default-policy -ttl=0 -display-name=&amp;quot;...&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Update &amp;lt;code&amp;gt;~/.claude.json&amp;lt;/code&amp;gt; (claude-mcp token) and &amp;lt;code&amp;gt;/library/openbao-app/.scripts-token&amp;lt;/code&amp;gt; (scripts-n8n token) to the new values, then update Vaultwarden.&lt;br /&gt;
&lt;br /&gt;
=== Adding a new secret path ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)&lt;br /&gt;
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=&amp;quot;$ROOT_TOKEN&amp;quot; openbao \&lt;br /&gt;
  bao kv put secret/NEWPATH field1=value1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;readonly-secrets&amp;lt;/code&amp;gt; policy uses &amp;lt;code&amp;gt;path &amp;quot;secret/data/*&amp;quot;&amp;lt;/code&amp;gt; so new paths are immediately readable by claude-mcp and scripts-n8n tokens — no policy update needed.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Gotchas (hard-won) ==&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Container runs as &amp;lt;code&amp;gt;user: &amp;quot;1000:1000&amp;quot;&amp;lt;/code&amp;gt; (mnw).&#039;&#039;&#039; Without this, files on &amp;lt;code&amp;gt;/library/openbao-app/data&amp;lt;/code&amp;gt; end up owned by UID 100 (&amp;lt;code&amp;gt;messagebus&amp;lt;/code&amp;gt; on host) which is confusing.&lt;br /&gt;
# &#039;&#039;&#039;Must set &amp;lt;code&amp;gt;SKIP_CHOWN=1&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;SKIP_SETCAP=1&amp;lt;/code&amp;gt; env vars.&#039;&#039;&#039; Without them, the entrypoint&#039;s &amp;lt;code&amp;gt;chown /openbao/logs&amp;lt;/code&amp;gt; fails under &amp;lt;code&amp;gt;set -e&amp;lt;/code&amp;gt; and the container restart-loops.&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;docker exec&amp;lt;/code&amp;gt; to bao CLI needs &amp;lt;code&amp;gt;-e BAO_ADDR=http://127.0.0.1:8200&amp;lt;/code&amp;gt;.&#039;&#039;&#039; The CLI defaults to https else and fails with &amp;quot;server gave HTTP response to HTTPS client&amp;quot;.&lt;br /&gt;
# &#039;&#039;&#039;Sealed on every restart.&#039;&#039;&#039; Raft + Shamir means the box re-seals on container start. Auto-unseal via the systemd service. Don&#039;t delete &amp;lt;code&amp;gt;.init-output.json&amp;lt;/code&amp;gt;.&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;bao kv put&amp;lt;/code&amp;gt; replaces the whole record.&#039;&#039;&#039; Always pass all fields you want kept, not just the changed one.&lt;br /&gt;
# &#039;&#039;&#039;Multi-line values fail with &amp;lt;code&amp;gt;bao kv put&amp;lt;/code&amp;gt; shell escaping.&#039;&#039;&#039; Use the HTTP API (curl/python urllib) directly for private keys, certs, etc.&lt;br /&gt;
# &#039;&#039;&#039;vault-mcp-server release binaries.&#039;&#039;&#039; GitHub releases page shows zero assets — download from &amp;lt;code&amp;gt;releases.hashicorp.com&amp;lt;/code&amp;gt; instead (&amp;lt;code&amp;gt;https://releases.hashicorp.com/vault-mcp-server/0.2.0/vault-mcp-server_0.2.0_linux_amd64.zip&amp;lt;/code&amp;gt;).&lt;br /&gt;
# &#039;&#039;&#039;&amp;lt;code&amp;gt;bao kv put field=@/path/to/file&amp;lt;/code&amp;gt; reads from container fs, not host.&#039;&#039;&#039; Either copy the file into the container first or use the HTTP API.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Related ==&lt;br /&gt;
&lt;br /&gt;
* Setup plan (preserved for archaeology): &amp;lt;code&amp;gt;plans/openbao-setup.md&amp;lt;/code&amp;gt;&lt;br /&gt;
* Memory note: &amp;lt;code&amp;gt;~/.claude/projects/.../memory/project_openbao.md&amp;lt;/code&amp;gt;&lt;br /&gt;
* CLAUDE.md &amp;quot;OpenBao Secret Store&amp;quot; section&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/Vaultwarden&amp;diff=8</id>
		<title>Services/Vaultwarden</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/Vaultwarden&amp;diff=8"/>
		<updated>2026-07-05T18:16:03Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/VAULTWARDEN.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/VAULTWARDEN.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Vaultwarden =&lt;br /&gt;
&lt;br /&gt;
Self-hosted Bitwarden-compatible password manager. The &#039;&#039;&#039;human-facing&#039;&#039;&#039; credential store. (OpenBao is the machine-facing one — see &amp;lt;code&amp;gt;services/OPENBAO.md&amp;lt;/code&amp;gt;.)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Container &amp;amp; Endpoints ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Item&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| Container&lt;br /&gt;
| &amp;lt;code&amp;gt;vaultwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Compose&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/vaultwarden-app/&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Public URL&lt;br /&gt;
| &amp;lt;code&amp;gt;https://vault.wilsoz.com&amp;lt;/code&amp;gt; (via VPS nginx → Tailscale → stronghold)&lt;br /&gt;
|-&lt;br /&gt;
| Internal port&lt;br /&gt;
| 8080&lt;br /&gt;
|-&lt;br /&gt;
| Auth&lt;br /&gt;
| OAuth2 via Authentik (provider PK 10)&lt;br /&gt;
|-&lt;br /&gt;
| Master folder&lt;br /&gt;
| &amp;quot;Stronghold Infrastructure&amp;quot; (all infrastructure credentials)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Claude Code Integration ==&lt;br /&gt;
&lt;br /&gt;
Claude reads Vaultwarden via the &amp;lt;code&amp;gt;bitwarden&amp;lt;/code&amp;gt; MCP server (configured at the user level — works in every Claude session on this machine).&lt;br /&gt;
&lt;br /&gt;
=== Files ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! File&lt;br /&gt;
! Mode&lt;br /&gt;
! Purpose&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.config/claude/vaultwarden.env&amp;lt;/code&amp;gt;&lt;br /&gt;
| 600&lt;br /&gt;
| BW_URL, BW_CLIENTID, BW_CLIENTSECRET, BW_EMAIL, BW_PASSWORD&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.config/claude/bw-mcp-start.sh&amp;lt;/code&amp;gt;&lt;br /&gt;
| 700&lt;br /&gt;
| Wrapper that unlocks vault and execs the MCP server&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.config/claude/bw-get.py&amp;lt;/code&amp;gt;&lt;br /&gt;
| 700&lt;br /&gt;
| One-off shell helper (&amp;lt;code&amp;gt;bw-get.py &amp;quot;Item Name&amp;quot; [field]&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;~/.claude.json&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;mcpServers.bitwarden&amp;lt;/code&amp;gt;&lt;br /&gt;
| —&lt;br /&gt;
| Registers the MCP with Claude Code&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/snap/bin/bw&amp;lt;/code&amp;gt;&lt;br /&gt;
| —&lt;br /&gt;
| Bitwarden CLI (snap-installed, official)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/home/mnw/.nvm/versions/node/v22.12.0/lib/node_modules/@bitwarden/mcp-server/&amp;lt;/code&amp;gt;&lt;br /&gt;
| —&lt;br /&gt;
| MCP server (npm-installed globally)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== How the wrapper works ===&lt;br /&gt;
&lt;br /&gt;
# Reads &amp;lt;code&amp;gt;vaultwarden.env&amp;lt;/code&amp;gt; via Python (avoids shell quoting issues with special chars in BW_PASSWORD).&lt;br /&gt;
# &#039;&#039;&#039;Refuses to start if &amp;lt;code&amp;gt;BW_URL != https://vault.wilsoz.com&amp;lt;/code&amp;gt;&#039;&#039;&#039; — guard against accidentally authing to the wrong server.&lt;br /&gt;
# Runs &amp;lt;code&amp;gt;bw config server&amp;lt;/code&amp;gt; (idempotent) so the CLI is pointed at the self-hosted instance.&lt;br /&gt;
# Logs in via &amp;lt;code&amp;gt;--apikey&amp;lt;/code&amp;gt; (uses BW_CLIENTID/BW_CLIENTSECRET) if not already logged in.&lt;br /&gt;
# Unlocks with &amp;lt;code&amp;gt;bw unlock --passwordenv BW_PASSWORD --raw&amp;lt;/code&amp;gt; and exports &amp;lt;code&amp;gt;BW_SESSION&amp;lt;/code&amp;gt;.&lt;br /&gt;
# &#039;&#039;&#039;Execs &amp;lt;code&amp;gt;node &amp;lt;path&amp;gt;/dist/index.js&amp;lt;/code&amp;gt;&#039;&#039;&#039; (not the &amp;lt;code&amp;gt;mcp-server-bitwarden&amp;lt;/code&amp;gt; symlink — see Known Bugs below).&lt;br /&gt;
&lt;br /&gt;
=== Available MCP tools ===&lt;br /&gt;
&lt;br /&gt;
58 tools, covering: &amp;lt;code&amp;gt;lock&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;sync&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;status&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;list&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;get&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;generate&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;create_item&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;create_folder&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;edit_item&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;edit_folder&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;delete&amp;lt;/code&amp;gt;, plus organization/group/policy/send management. Used like any other MCP — e.g., &amp;quot;list items matching voip&amp;quot; or &amp;quot;create a new item named X&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Asking the User for Credentials ==&lt;br /&gt;
&lt;br /&gt;
When you need a credential that isn&#039;t in Vaultwarden yet (e.g., a new third-party API key the user just got), &#039;&#039;&#039;never ask the user to paste it in chat&#039;&#039;&#039;. Instead:&lt;br /&gt;
&lt;br /&gt;
=== Pattern: &amp;quot;Save it to Vaultwarden, then I&#039;ll fetch it&amp;quot; ===&lt;br /&gt;
&lt;br /&gt;
Ask the user to do this:&lt;br /&gt;
&lt;br /&gt;
:Please add it to Vaultwarden:&lt;br /&gt;
:1. Open https://vault.wilsoz.com&lt;br /&gt;
:2. New Item → Folder: &#039;&#039;&#039;Stronghold Infrastructure&#039;&#039;&#039;&lt;br /&gt;
:3. Name: &amp;lt;code&amp;gt;&amp;lt;service-name&amp;gt;&amp;lt;/code&amp;gt; (e.g., &amp;lt;code&amp;gt;voip.ms&amp;lt;/code&amp;gt;)&lt;br /&gt;
:4. Add fields:&lt;br /&gt;
:   - &amp;lt;code&amp;gt;username&amp;lt;/code&amp;gt; = …&lt;br /&gt;
:   - &amp;lt;code&amp;gt;password&amp;lt;/code&amp;gt; = …&lt;br /&gt;
:   - Custom fields for anything else (e.g., &amp;lt;code&amp;gt;api-key&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;did&amp;lt;/code&amp;gt;)&lt;br /&gt;
:5. Save, then tell me &amp;quot;done&amp;quot;&lt;br /&gt;
:&lt;br /&gt;
:I&#039;ll fetch it from Vaultwarden via the MCP — you never need to paste secrets here.&lt;br /&gt;
&lt;br /&gt;
Then on your side:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
[use bitwarden MCP] list items matching &amp;quot;&amp;lt;service-name&amp;gt;&amp;quot;&lt;br /&gt;
[use bitwarden MCP] get item &amp;lt;id&amp;gt;&lt;br /&gt;
[use vault MCP] write secret/&amp;lt;service-name&amp;gt; with the retrieved fields&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Why this matters ===&lt;br /&gt;
&lt;br /&gt;
* Chat transcripts are logged. Secrets in chat = secrets in logs forever.&lt;br /&gt;
&#039;&#039; The user has explicitly said: &#039;&#039;never store or surface credentials, use Vaultwarden + OpenBao.*&lt;br /&gt;
* If you accidentally print a secret (e.g., via &amp;lt;code&amp;gt;bash -x&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;set -x&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;cat env-file&amp;lt;/code&amp;gt;, or echoing a fetched variable) — &#039;&#039;&#039;stop, tell the user immediately, and ask them to rotate that credential.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Hard-Won Gotchas ==&lt;br /&gt;
&lt;br /&gt;
=== 1. &amp;lt;code&amp;gt;mcp-server-bitwarden&amp;lt;/code&amp;gt; symlink does not work (v2026.2.0) ===&lt;br /&gt;
&lt;br /&gt;
The npm package installs a symlink at &amp;lt;code&amp;gt;…/bin/mcp-server-bitwarden&amp;lt;/code&amp;gt; → &amp;lt;code&amp;gt;…/dist/index.js&amp;lt;/code&amp;gt;. The script at the end of &amp;lt;code&amp;gt;index.js&amp;lt;/code&amp;gt; checks &amp;lt;code&amp;gt;process.argv[1].endsWith(&#039;index.js&#039;)&amp;lt;/code&amp;gt; to decide whether to start the server. The symlink path doesn&#039;t end in &amp;lt;code&amp;gt;index.js&amp;lt;/code&amp;gt;, so the server never starts — it just exits silently with code 0.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Workaround:&#039;&#039;&#039; invoke via &amp;lt;code&amp;gt;node&amp;lt;/code&amp;gt; with the full path:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
exec node &amp;quot;$(npm root -g)/@bitwarden/mcp-server/dist/index.js&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Never use &amp;lt;code&amp;gt;bash -x&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;set -x&amp;lt;/code&amp;gt; near &amp;lt;code&amp;gt;source vaultwarden.env&amp;lt;/code&amp;gt; ===&lt;br /&gt;
&lt;br /&gt;
Bash tracing prints every variable assignment to stderr, including BW_PASSWORD. &#039;&#039;&#039;This will leak the master password into your terminal scrollback, into logs, and into Claude conversation transcripts.&#039;&#039;&#039; Use targeted &amp;lt;code&amp;gt;echo&amp;lt;/code&amp;gt; debugging or &amp;lt;code&amp;gt;python3&amp;lt;/code&amp;gt; parsing instead.&lt;br /&gt;
&lt;br /&gt;
=== 3. &amp;lt;code&amp;gt;bw login&amp;lt;/code&amp;gt; defaults to &amp;lt;code&amp;gt;vault.bitwarden.com&amp;lt;/code&amp;gt; ===&lt;br /&gt;
&lt;br /&gt;
If the bw CLI&#039;s local config gets reset (&amp;lt;code&amp;gt;~/.config/Bitwarden CLI/data.json&amp;lt;/code&amp;gt; deleted), it will default to the public Bitwarden cloud. Running &amp;lt;code&amp;gt;bw login&amp;lt;/code&amp;gt; against the wrong server &#039;&#039;&#039;sends your credentials to Bitwarden, Inc.&#039;&#039;&#039; Always run &amp;lt;code&amp;gt;bw config server &amp;quot;$BW_URL&amp;quot;&amp;lt;/code&amp;gt; first. The wrapper script enforces this with a refuse-to-start guard.&lt;br /&gt;
&lt;br /&gt;
=== 4. &amp;lt;code&amp;gt;bw login&amp;lt;/code&amp;gt; vs &amp;lt;code&amp;gt;bw unlock&amp;lt;/code&amp;gt; ===&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;bw login&amp;lt;/code&amp;gt; = authenticate the device to the server (once per device, persisted in &amp;lt;code&amp;gt;data.json&amp;lt;/code&amp;gt;).&lt;br /&gt;
* &amp;lt;code&amp;gt;bw unlock&amp;lt;/code&amp;gt; = decrypt the local vault cache (once per session, returns BW_SESSION).&lt;br /&gt;
* The MCP server needs both. The wrapper handles login idempotently; unlock runs on every start.&lt;br /&gt;
&lt;br /&gt;
=== 5. &amp;lt;code&amp;gt;bw login&amp;lt;/code&amp;gt; with email + password fails on Vaultwarden ===&lt;br /&gt;
&lt;br /&gt;
Vaultwarden / Bitwarden self-hosted requires &amp;lt;code&amp;gt;bw login --apikey&amp;lt;/code&amp;gt; (client credentials) rather than &amp;lt;code&amp;gt;bw login email password&amp;lt;/code&amp;gt;. The email/password flow expects 2FA prompts and an interactive TTY.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Rotating BW_PASSWORD ==&lt;br /&gt;
&lt;br /&gt;
If the master password leaks (e.g., bash -x mistake):&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;In Vaultwarden web UI:&#039;&#039;&#039; Account Settings → Change Master Password.&lt;br /&gt;
# &#039;&#039;&#039;On stronghold:&#039;&#039;&#039; Update &amp;lt;code&amp;gt;~/.config/claude/vaultwarden.env&amp;lt;/code&amp;gt;:&lt;br /&gt;
   ```bash&lt;br /&gt;
   # Edit the line: BW_PASSWORD=&#039;new-password-here&#039;&lt;br /&gt;
   chmod 600 ~/.config/claude/vaultwarden.env  # verify mode still 600&lt;br /&gt;
   ```&lt;br /&gt;
# &#039;&#039;&#039;Re-login to bw:&#039;&#039;&#039;&lt;br /&gt;
   ```bash&lt;br /&gt;
   bw logout&lt;br /&gt;
   set -a; source ~/.config/claude/vaultwarden.env; set +a&lt;br /&gt;
   bw config server &amp;quot;$BW_URL&amp;quot;&lt;br /&gt;
   bw login --apikey&lt;br /&gt;
   ```&lt;br /&gt;
# &#039;&#039;&#039;Next Claude session&#039;&#039;&#039; will pick up the new password from the env file automatically.&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;BW_CLIENTSECRET&amp;lt;/code&amp;gt; (API key) is a separate value — only rotate it if it leaks. Rotating it requires regenerating in Account Settings → Security → Keys → View API Key.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Related Docs ==&lt;br /&gt;
&lt;br /&gt;
* &amp;lt;code&amp;gt;services/OPENBAO.md&amp;lt;/code&amp;gt; — the machine-facing companion store&lt;br /&gt;
* &amp;lt;code&amp;gt;services/OPENBAO.agent.md&amp;lt;/code&amp;gt; — agent-focused decision tree for credential access&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
	<entry>
		<id>https://wiki.wilsoz.com/index.php?title=Services/Authentik&amp;diff=6</id>
		<title>Services/Authentik</title>
		<link rel="alternate" type="text/html" href="https://wiki.wilsoz.com/index.php?title=Services/Authentik&amp;diff=6"/>
		<updated>2026-07-05T18:16:02Z</updated>

		<summary type="html">&lt;p&gt;Wikibot: Migrated from services/AUTHENTIK.md&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&#039;&#039;As of: 2026-07-05 — migrated from `services/AUTHENTIK.md`&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
= Authentik — Identity &amp;amp; SSO Provider =&lt;br /&gt;
&lt;br /&gt;
Complete setup &amp;amp; operations guide for Authentik (identity provider, OAuth2/OIDC, LDAP directory service).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Criticality:&#039;&#039;&#039; &#039;&#039;&#039;CRITICAL — The lynchpin.&#039;&#039;&#039; Everything on Stronghold depends on Authentik for authentication. Without it, most other services are inaccessible or in an inconsistent state. Must be recovered first in any disaster scenario.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Authentik&#039;&#039;&#039; is a self-hosted identity provider that serves as the single source of truth for authentication across Stronghold. It provides:&lt;br /&gt;
* &#039;&#039;&#039;OAuth2 / OIDC&#039;&#039;&#039; providers for web apps (Immich, Memos, Linkwarden, Forgejo, Vaultwarden, Grafana, n8n, Matrix, Cloudflare Access)&lt;br /&gt;
* &#039;&#039;&#039;LDAP directory&#039;&#039;&#039; for apps that don&#039;t support OAuth (Nextcloud, Jellyfin, Stalwart mail server)&lt;br /&gt;
* &#039;&#039;&#039;Group membership&#039;&#039;&#039; for role-based access (authentik Admins, family-and-friends, email provisioning groups)&lt;br /&gt;
* &#039;&#039;&#039;User management&#039;&#039;&#039; — all users are defined here; other services sync from Authentik&lt;br /&gt;
* &#039;&#039;&#039;API tokens&#039;&#039;&#039; for programmatic access (n8n, CI/CD, automation)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Users:&#039;&#039;&#039; &lt;br /&gt;
* &amp;lt;code&amp;gt;marcus&amp;lt;/code&amp;gt; (primary admin user, member of authentik Admins)&lt;br /&gt;
* &amp;lt;code&amp;gt;akadmin&amp;lt;/code&amp;gt; (backup admin user)&lt;br /&gt;
* Service accounts: &amp;lt;code&amp;gt;stalwart-svc&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;nextcloud-svc&amp;lt;/code&amp;gt; (for LDAP directory access)&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Current Version:&#039;&#039;&#039; 2025.10.3&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Access:&#039;&#039;&#039; https://key.wilsoz.com/if/admin/&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Service Information ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Property&lt;br /&gt;
! Value&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Compose file&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/authentik-app/docker-compose.yml&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Containers&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-app_server_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;authentik-app_worker_1&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;authentik-app_postgresql_1&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Database&#039;&#039;&#039;&lt;br /&gt;
| PostgreSQL 16 (in-container, persisted to Docker volume &amp;lt;code&amp;gt;database&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HTTP Port&#039;&#039;&#039;&lt;br /&gt;
| 9000 (external via 9443 HTTPS via reverse proxy)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;HTTPS Port&#039;&#039;&#039;&lt;br /&gt;
| 9443 (internal, for direct connections)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;LDAP Outpost Port&#039;&#039;&#039;&lt;br /&gt;
| 389 (via LDAP Outpost container; internal only, reaches stronghold via &amp;lt;code&amp;gt;http://172.17.0.1:9000&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Data volumes&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;/library/authentik-app/media&amp;lt;/code&amp;gt; (custom templates, branding)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Database volume&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-app_database&amp;lt;/code&amp;gt; (PostgreSQL data)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Networks&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;authentik-app_default&amp;lt;/code&amp;gt; (internal Docker network, MTU 1280)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Image versions&#039;&#039;&#039;&lt;br /&gt;
| server/worker: &amp;lt;code&amp;gt;ghcr.io/goauthentik/server:2025.10.3&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Restart policy&#039;&#039;&#039;&lt;br /&gt;
| &amp;lt;code&amp;gt;unless-stopped&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Initial Setup from Scratch ==&lt;br /&gt;
&lt;br /&gt;
=== Prerequisites ===&lt;br /&gt;
&lt;br /&gt;
* Docker and docker-compose installed&lt;br /&gt;
* Network access to PostgreSQL (internal container or external)&lt;br /&gt;
* Cloudflare DNS configured for &amp;lt;code&amp;gt;key.wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
* VPS reverse proxy (nginx) configured to forward to stronghold:9000&lt;br /&gt;
&lt;br /&gt;
=== 1. Create Directory &amp;amp; Files ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p /library/authentik-app&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Create docker-compose.yml ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;yaml&amp;quot;&amp;gt;&lt;br /&gt;
version: &amp;quot;3.8&amp;quot;&lt;br /&gt;
&lt;br /&gt;
services:&lt;br /&gt;
  postgresql:&lt;br /&gt;
    image: docker.io/library/postgres:16-alpine&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    healthcheck:&lt;br /&gt;
      test: [&amp;quot;CMD-SHELL&amp;quot;, &amp;quot;pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}&amp;quot;]&lt;br /&gt;
      interval: 30s&lt;br /&gt;
      timeout: 5s&lt;br /&gt;
      retries: 5&lt;br /&gt;
      start_period: 20s&lt;br /&gt;
    volumes:&lt;br /&gt;
      - database:/var/lib/postgresql/data&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
    environment:&lt;br /&gt;
      POSTGRES_DB: ${PG_DB:-authentik}&lt;br /&gt;
      POSTGRES_USER: ${PG_USER:-authentik}&lt;br /&gt;
      POSTGRES_PASSWORD: ${PG_PASS:?database password required}&lt;br /&gt;
&lt;br /&gt;
  server:&lt;br /&gt;
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.3}&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    command: server&lt;br /&gt;
    ports:&lt;br /&gt;
      - &amp;quot;${COMPOSE_PORT_HTTP:-9000}:9000&amp;quot;&lt;br /&gt;
      - &amp;quot;${COMPOSE_PORT_HTTPS:-9443}:9443&amp;quot;&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
    environment:&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__HOST: postgresql&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}&lt;br /&gt;
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}&lt;br /&gt;
    volumes:&lt;br /&gt;
      - ./media:/media&lt;br /&gt;
      - ./custom-templates:/templates&lt;br /&gt;
    depends_on:&lt;br /&gt;
      postgresql:&lt;br /&gt;
        condition: service_healthy&lt;br /&gt;
&lt;br /&gt;
  worker:&lt;br /&gt;
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.3}&lt;br /&gt;
    restart: unless-stopped&lt;br /&gt;
    command: worker&lt;br /&gt;
    user: root&lt;br /&gt;
    env_file:&lt;br /&gt;
      - .env&lt;br /&gt;
    environment:&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__HOST: postgresql&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}&lt;br /&gt;
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}&lt;br /&gt;
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}&lt;br /&gt;
    volumes:&lt;br /&gt;
      - /var/run/docker.sock:/var/run/docker.sock&lt;br /&gt;
      - ./media:/media&lt;br /&gt;
      - ./certs:/certs&lt;br /&gt;
      - ./custom-templates:/templates&lt;br /&gt;
    depends_on:&lt;br /&gt;
      postgresql:&lt;br /&gt;
        condition: service_healthy&lt;br /&gt;
&lt;br /&gt;
volumes:&lt;br /&gt;
  database:&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. Create .env File ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# PostgreSQL&lt;br /&gt;
PG_DB=authentik&lt;br /&gt;
PG_USER=authentik&lt;br /&gt;
PG_PASS=&amp;lt;generate-strong-password-32-chars&amp;gt;&lt;br /&gt;
&lt;br /&gt;
# Authentik&lt;br /&gt;
AUTHENTIK_SECRET_KEY=&amp;lt;generate-strong-secret-key-50-chars&amp;gt;&lt;br /&gt;
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server&lt;br /&gt;
AUTHENTIK_TAG=2025.10.3&lt;br /&gt;
&lt;br /&gt;
# Ports&lt;br /&gt;
COMPOSE_PORT_HTTP=9000&lt;br /&gt;
COMPOSE_PORT_HTTPS=9443&lt;br /&gt;
&lt;br /&gt;
# Bootstrap (initial admin)&lt;br /&gt;
AUTHENTIK_BOOTSTRAP_PASSWORD=&amp;lt;temporary-init-password&amp;gt;&lt;br /&gt;
AUTHENTIK_BOOTSTRAP_TOKEN=&amp;lt;temporary-init-token&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Important:&#039;&#039;&#039; Generate secure random values for passwords and secrets:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
openssl rand -base64 32  # For PG_PASS and AUTHENTIK_SECRET_KEY&lt;br /&gt;
openssl rand -hex 16     # For AUTHENTIK_BOOTSTRAP_TOKEN&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 4. Start Authentik ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Wait for PostgreSQL to be healthy and server to start (~30-60 seconds):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker compose logs -f server&lt;br /&gt;
# Watch for &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5. Initial Admin User ===&lt;br /&gt;
&lt;br /&gt;
After startup, navigate to &amp;lt;code&amp;gt;https://key.wilsoz.com/if/admin/&amp;lt;/code&amp;gt;:&lt;br /&gt;
* Username: &amp;lt;code&amp;gt;akadmin&amp;lt;/code&amp;gt;&lt;br /&gt;
* Password: value of &amp;lt;code&amp;gt;AUTHENTIK_BOOTSTRAP_PASSWORD&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;First steps in Admin UI:&#039;&#039;&#039;&lt;br /&gt;
# Go to &#039;&#039;&#039;Directory → Users → Create&#039;&#039;&#039;&lt;br /&gt;
** Username: &amp;lt;code&amp;gt;marcus&amp;lt;/code&amp;gt;&lt;br /&gt;
** Email: &amp;lt;code&amp;gt;marcus@wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
** Set password (or let user set on first login)&lt;br /&gt;
&#039;&#039;&#039; Groups: Add to &#039;&#039;&#039;authentik Admins**&lt;br /&gt;
&lt;br /&gt;
# Go to &#039;&#039;&#039;Directory → Groups → Create&#039;&#039;&#039;&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;authentik Admins** (if not already created)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;family-and-friends** (for app access)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;craniumslows-email** (for email provisioning)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;wilsoz-email** (for email provisioning)&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;ldap-search** (for LDAP bind accounts)&lt;br /&gt;
&lt;br /&gt;
# Add service users:&lt;br /&gt;
&#039;&#039;&#039; &#039;&#039;&#039;Directory → Users → Create**&lt;br /&gt;
** Username: &amp;lt;code&amp;gt;stalwart-svc&amp;lt;/code&amp;gt;, Email: &amp;lt;code&amp;gt;stalwart-svc@internal&amp;lt;/code&amp;gt;&lt;br /&gt;
&#039;&#039;&#039; Groups: Add to &#039;&#039;&#039;ldap-search**&lt;br /&gt;
** Username: &amp;lt;code&amp;gt;nextcloud-svc&amp;lt;/code&amp;gt;, Email: &amp;lt;code&amp;gt;nextcloud-svc@internal&amp;lt;/code&amp;gt;&lt;br /&gt;
&#039;&#039;&#039; Groups: Add to &#039;&#039;&#039;ldap-search**&lt;br /&gt;
&lt;br /&gt;
=== 6. Set Up LDAP Outpost ===&lt;br /&gt;
&lt;br /&gt;
LDAP is required for Stalwart (mail server), Nextcloud (CalDAV/CardDAV), and Jellyfin (media server) to authenticate.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;In Authentik Admin UI:&#039;&#039;&#039;&lt;br /&gt;
# Go to &#039;&#039;&#039;Infrastructure → Outposts → Create&#039;&#039;&#039;&lt;br /&gt;
** Name: &amp;lt;code&amp;gt;authentik-ldap&amp;lt;/code&amp;gt;&lt;br /&gt;
** Type: LDAP&lt;br /&gt;
# In &#039;&#039;&#039;Directory → LDAP → Configure LDAP&#039;&#039;&#039;&lt;br /&gt;
** Base DN: &amp;lt;code&amp;gt;dc=ldap,dc=goauthentik,dc=io&amp;lt;/code&amp;gt;&lt;br /&gt;
** User base: &amp;lt;code&amp;gt;ou=users,dc=ldap,dc=goauthentik,dc=io&amp;lt;/code&amp;gt;&lt;br /&gt;
# Assign the LDAP outpost to the LDAP provider&lt;br /&gt;
&lt;br /&gt;
The LDAP server will be reachable at &amp;lt;code&amp;gt;172.17.0.1:389&amp;lt;/code&amp;gt; from other containers.&lt;br /&gt;
&lt;br /&gt;
=== 7. Configure OAuth2 Providers ===&lt;br /&gt;
&lt;br /&gt;
For each OAuth application (Immich, Memos, Linkwarden, etc.), create an OAuth2 provider in Authentik:&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Immich example:&#039;&#039;&#039;&lt;br /&gt;
# Go to &#039;&#039;&#039;Applications → Providers → Create OAuth2/OpenID Provider&#039;&#039;&#039;&lt;br /&gt;
** Name: &amp;lt;code&amp;gt;immich-oauth&amp;lt;/code&amp;gt;&lt;br /&gt;
** Authorization flow: &amp;lt;code&amp;gt;default-authentication-flow&amp;lt;/code&amp;gt;&lt;br /&gt;
** Client type: &amp;lt;code&amp;gt;Confidential&amp;lt;/code&amp;gt;&lt;br /&gt;
** Redirect URIs:&lt;br /&gt;
     ```&lt;br /&gt;
     https://photos.wilsoz.com/auth/login/oauth2/callback&lt;br /&gt;
     https://photos.wilsoz.com/auth/login/oauth2/mobile-redirect&lt;br /&gt;
     ```&lt;br /&gt;
# &#039;&#039;&#039;Save&#039;&#039;&#039; — copy the generated Client ID and Client Secret&lt;br /&gt;
# Add property mappings:&lt;br /&gt;
** &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;openid&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
** &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;email&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
** &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;profile&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
# Enable: &amp;lt;code&amp;gt;include_claims_in_id_token&amp;lt;/code&amp;gt;&lt;br /&gt;
# &#039;&#039;&#039;Assign a signing key&#039;&#039;&#039; (see Critical Gotchas section below)&lt;br /&gt;
&lt;br /&gt;
Repeat for each app.&lt;br /&gt;
&lt;br /&gt;
=== 8. Configure VPS Reverse Proxy ===&lt;br /&gt;
&lt;br /&gt;
On VPS, add nginx server block:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;nginx&amp;quot;&amp;gt;&lt;br /&gt;
server {&lt;br /&gt;
    listen 443 ssl http2;&lt;br /&gt;
    server_name key.wilsoz.com;&lt;br /&gt;
&lt;br /&gt;
    ssl_certificate /etc/letsencrypt/live/key.wilsoz.com/fullchain.pem;&lt;br /&gt;
    ssl_certificate_key /etc/letsencrypt/live/key.wilsoz.com/privkey.pem;&lt;br /&gt;
&lt;br /&gt;
    location / {&lt;br /&gt;
        proxy_pass http://100.64.207.155:9000;&lt;br /&gt;
        proxy_set_header Host $host;&lt;br /&gt;
        proxy_set_header X-Real-IP $remote_addr;&lt;br /&gt;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;&lt;br /&gt;
        proxy_set_header X-Forwarded-Proto $scheme;&lt;br /&gt;
        proxy_http_version 1.1;&lt;br /&gt;
        proxy_set_header Upgrade $http_upgrade;&lt;br /&gt;
        proxy_set_header Connection &amp;quot;upgrade&amp;quot;;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Reload nginx:&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
sudo systemctl reload nginx&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disaster Mode A: Configuration Corruption ==&lt;br /&gt;
&lt;br /&gt;
=== Symptoms ===&lt;br /&gt;
&lt;br /&gt;
* Can&#039;t log into admin interface&lt;br /&gt;
* OAuth providers missing or broken&lt;br /&gt;
* LDAP directory returning errors&lt;br /&gt;
* Groups or users deleted accidentally&lt;br /&gt;
* Provider credentials lost&lt;br /&gt;
&lt;br /&gt;
=== Recovery Steps ===&lt;br /&gt;
&lt;br /&gt;
⚠️ &#039;&#039;&#039;This mode resets Authentik to a fresh state. User data will be lost unless you have a PostgreSQL backup (see Disaster Mode B).&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==== Option 1: Full Reset (if Disaster Mode B not available) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
&lt;br /&gt;
# Stop all containers&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Remove the database volume (this deletes all Authentik data)&lt;br /&gt;
docker volume rm authentik-app_database&lt;br /&gt;
&lt;br /&gt;
# Start from scratch&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Wait for startup&lt;br /&gt;
sleep 30&lt;br /&gt;
docker logs server | grep &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# Follow &amp;quot;Initial Setup from Scratch&amp;quot; section above&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==== Option 2: Selective Reset (if you only need to fix one app) ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Access the Django shell&lt;br /&gt;
docker exec authentik-app_server_1 ak shell&lt;br /&gt;
&lt;br /&gt;
# Example: Fix an OAuth provider&#039;s signing key&lt;br /&gt;
from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
from authentik.crypto.models import CertificateKeyPair&lt;br /&gt;
&lt;br /&gt;
p = OAuth2Provider.objects.get(name=&#039;immich-oauth&#039;)&lt;br /&gt;
jwt_key = CertificateKeyPair.objects.get(name=&#039;authentik Internal JWT Certificate&#039;)&lt;br /&gt;
p.signing_key = jwt_key&lt;br /&gt;
p.save()&lt;br /&gt;
print(f&#039;Updated {p.name} with JWT signing key&#039;)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Disaster Mode B: Drive Failure / Full Recovery ==&lt;br /&gt;
&lt;br /&gt;
=== If Backups Accessible ===&lt;br /&gt;
&lt;br /&gt;
Authentik data is backed up in two ways:&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;PostgreSQL dump&#039;&#039;&#039; at Hetzner (in &amp;lt;code&amp;gt;/dumps/authentik-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt;)&lt;br /&gt;
# &#039;&#039;&#039;Media files&#039;&#039;&#039; at Hetzner (in &amp;lt;code&amp;gt;/data/authentik/&amp;lt;/code&amp;gt;)&lt;br /&gt;
&lt;br /&gt;
==== Recovery procedure: ====&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# 1. Set up fresh Authentik (follow &amp;quot;Initial Setup from Scratch&amp;quot; through step 4)&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# 2. Wait for PostgreSQL to be ready&lt;br /&gt;
sleep 30&lt;br /&gt;
&lt;br /&gt;
# 3. Drop the initial empty database&lt;br /&gt;
docker exec authentik-app_postgresql_1 psql -U authentik -d postgres -c &amp;quot;DROP DATABASE authentik;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# 4. Create a fresh empty database&lt;br /&gt;
docker exec authentik-app_postgresql_1 psql -U authentik -d postgres -c &amp;quot;CREATE DATABASE authentik;&amp;quot;&lt;br /&gt;
&lt;br /&gt;
# 5. Restore from backup dump (substitute actual date)&lt;br /&gt;
# First, extract the backup from Hetzner:&lt;br /&gt;
# (You&#039;ll need ssh-key access to u540853.your-storagebox.de port 23)&lt;br /&gt;
ssh -p 23 u540853@u540853.your-storagebox.de &amp;quot;cat backups/authentik-20260512.sql.gz&amp;quot; | \&lt;br /&gt;
  gunzip | docker exec -i authentik-app_postgresql_1 psql -U authentik -d authentik&lt;br /&gt;
&lt;br /&gt;
# 6. Restore media files&lt;br /&gt;
rsync -av -e &amp;quot;ssh -p 23&amp;quot; u540853@u540853.your-storagebox.de:backups/data/authentik/ /library/authentik-app/media/&lt;br /&gt;
&lt;br /&gt;
# 7. Restart Authentik to pick up restored data&lt;br /&gt;
docker compose restart server worker&lt;br /&gt;
&lt;br /&gt;
# 8. Verify&lt;br /&gt;
docker logs server | grep &amp;quot;Application startup complete&amp;quot;&lt;br /&gt;
curl -s https://key.wilsoz.com/if/admin/ | grep -q &amp;quot;authentik&amp;quot; &amp;amp;&amp;amp; echo &amp;quot;Admin UI responding&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== If Backups NOT Accessible ===&lt;br /&gt;
&lt;br /&gt;
Fall back to Mode A (full reset). Note: &#039;&#039;&#039;All user data, OAuth provider credentials, and LDAP settings will be lost.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
After reset, you&#039;ll need to:&lt;br /&gt;
# Recreate all users and groups&lt;br /&gt;
# Recreate all OAuth2 providers and get new Client IDs/Secrets&lt;br /&gt;
# Update all dependent services (.env files) with new credentials&lt;br /&gt;
# Recreate LDAP configuration and outpost&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Data Locations ==&lt;br /&gt;
&lt;br /&gt;
=== Authentik Data on Host ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
/library/authentik-app/&lt;br /&gt;
├── docker-compose.yml       (service definition)&lt;br /&gt;
├── .env                     (secrets: PG_PASS, AUTHENTIK_SECRET_KEY)&lt;br /&gt;
├── media/                   (custom templates, branding, uploads)&lt;br /&gt;
└── custom-templates/        (HTML templates for login pages, etc.)&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== PostgreSQL Database Volume ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;text&amp;quot;&amp;gt;&lt;br /&gt;
authentik-app_database (Docker volume)&lt;br /&gt;
├── postgresql/              (database files)&lt;br /&gt;
└── ...&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Contains:&lt;br /&gt;
* All users, groups, group memberships&lt;br /&gt;
* All OAuth2 providers, applications, credentials&lt;br /&gt;
* All LDAP configuration&lt;br /&gt;
* All API tokens&lt;br /&gt;
* All authenticator devices (TOTP, WebAuthn)&lt;br /&gt;
* All event logs&lt;br /&gt;
&lt;br /&gt;
=== Backup Locations ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL dump:&#039;&#039;&#039; Hetzner &amp;lt;code&amp;gt;/dumps/authentik-YYYYMMDD.sql.gz&amp;lt;/code&amp;gt; (daily at 03:00 CST)&lt;br /&gt;
* &#039;&#039;&#039;Media files:&#039;&#039;&#039; Hetzner &amp;lt;code&amp;gt;/data/authentik/&amp;lt;/code&amp;gt; (daily Restic snapshot)&lt;br /&gt;
* &#039;&#039;&#039;Filesystem snapshot:&#039;&#039;&#039; Timeshift snapshot on &amp;lt;code&amp;gt;/dev/md1&amp;lt;/code&amp;gt; (weekly)&lt;br /&gt;
&lt;br /&gt;
=== What&#039;s NOT Backed Up ===&lt;br /&gt;
&lt;br /&gt;
* Container images (re-downloadable from Docker Hub)&lt;br /&gt;
* node_modules and build artifacts (not present in Authentik)&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Critical Gotchas ==&lt;br /&gt;
&lt;br /&gt;
=== 1. OAuth2 Signing Key Assignment — &#039;&#039;&#039;MANDATORY&#039;&#039;&#039; ===&lt;br /&gt;
&lt;br /&gt;
⚠️ &#039;&#039;&#039;If you don&#039;t assign a signing key to an OAuth2 provider, the JWKS endpoint returns an empty object &amp;lt;code&amp;gt;{}&amp;lt;/code&amp;gt;, and all clients trying to validate tokens will fail.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Every OAuth2 provider MUST have:&#039;&#039;&#039;&lt;br /&gt;
* A signing key assigned: Use &#039;&#039;&#039;&amp;lt;code&amp;gt;authentik Internal JWT Certificate&amp;lt;/code&amp;gt;&#039;&#039;&#039; (HS256 algorithm)&lt;br /&gt;
* &#039;&#039;&#039;NOT&#039;&#039;&#039; &amp;lt;code&amp;gt;authentik Self-signed Certificate&amp;lt;/code&amp;gt; (RS256) — some apps like Mealie reject RS256&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Assignment code:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
from authentik.crypto.models import CertificateKeyPair&lt;br /&gt;
&lt;br /&gt;
p = OAuth2Provider.objects.get(name=&#039;immich-oauth&#039;)&lt;br /&gt;
jwt_key = CertificateKeyPair.objects.get(name=&#039;authentik Internal JWT Certificate&#039;)&lt;br /&gt;
p.signing_key = jwt_key&lt;br /&gt;
p.save()&lt;br /&gt;
print(f&#039;Assigned signing key to {p.name}&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 2. Property Mappings for OAuth Scopes ===&lt;br /&gt;
&lt;br /&gt;
OAuth providers need explicit property mappings for scopes like &amp;lt;code&amp;gt;email&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;profile&amp;lt;/code&amp;gt;. Without them:&lt;br /&gt;
* OIDC discovery endpoint returns only &amp;lt;code&amp;gt;[&amp;quot;openid&amp;quot;]&amp;lt;/code&amp;gt;&lt;br /&gt;
* Apps can&#039;t get user email or name from token&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Always add these three mappings to every OAuth provider:&#039;&#039;&#039;&lt;br /&gt;
* &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;openid&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;email&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;authentik default OAuth Mapping: OpenID &#039;profile&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Also enable: &amp;lt;code&amp;gt;include_claims_in_id_token = True&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 3. LDAP Outpost Internal-Only ===&lt;br /&gt;
&lt;br /&gt;
The LDAP outpost (port 389) is &#039;&#039;&#039;internal to Docker networking only&#039;&#039;&#039;. External apps can&#039;t reach it directly. Instead:&lt;br /&gt;
* Internal Docker containers reach it at &amp;lt;code&amp;gt;172.17.0.1:389&amp;lt;/code&amp;gt;&lt;br /&gt;
* Other hosts on LAN reach it via Stalwart LDAP passthrough (Stalwart exposes LDAP to port 389 externally)&lt;br /&gt;
&lt;br /&gt;
=== 4. Bootstrap Credentials Not Persisted ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;AUTHENTIK_BOOTSTRAP_PASSWORD&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;AUTHENTIK_BOOTSTRAP_TOKEN&amp;lt;/code&amp;gt; in &amp;lt;code&amp;gt;.env&amp;lt;/code&amp;gt; are only used on first startup. After that, they&#039;re ignored. If you lose them:&lt;br /&gt;
* You still have the &amp;lt;code&amp;gt;akadmin&amp;lt;/code&amp;gt; user created from bootstrap&lt;br /&gt;
* If you lose the akadmin password, reset via Django shell:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.core.models import User&lt;br /&gt;
u = User.objects.get(username=&#039;akadmin&#039;)&lt;br /&gt;
u.set_password(&#039;NewPassword123&#039;)&lt;br /&gt;
u.save()&lt;br /&gt;
print(f&#039;Reset password for {u.username}&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 5. Secret Key Rotation (Rare but Important) ===&lt;br /&gt;
&lt;br /&gt;
The &amp;lt;code&amp;gt;AUTHENTIK_SECRET_KEY&amp;lt;/code&amp;gt; is used to encrypt stored secrets (OAuth credentials, etc.). &#039;&#039;&#039;If you change this .env value, all encrypted secrets become unreadable.&#039;&#039;&#039; Only change if:&lt;br /&gt;
* You&#039;re migrating to a new server&lt;br /&gt;
* You suspect key compromise&lt;br /&gt;
&lt;br /&gt;
When rotating:&lt;br /&gt;
# Export all OAuth providers&#039; client secrets before changing&lt;br /&gt;
# Update AUTHENTIK_SECRET_KEY&lt;br /&gt;
# Restart (secrets will need to be re-entered)&lt;br /&gt;
&lt;br /&gt;
=== 6. Group Membership Propagation Delay ===&lt;br /&gt;
&lt;br /&gt;
Changes to group membership (e.g., adding a user to a group) are propagated asynchronously via the worker. If an app isn&#039;t seeing the new group:&lt;br /&gt;
* Wait 5-10 seconds&lt;br /&gt;
* Restart the worker: &amp;lt;code&amp;gt;docker compose restart worker&amp;lt;/code&amp;gt;&lt;br /&gt;
* Check the worker logs: &amp;lt;code&amp;gt;docker logs worker | tail -20&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== 7. API Token Expiry ===&lt;br /&gt;
&lt;br /&gt;
API tokens can have an expiry. The current claude-api-token (created 2026-02-16) is set to &#039;&#039;&#039;never expire&#039;&#039;&#039;. Check token settings:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
from authentik.core.models import Token&lt;br /&gt;
t = Token.objects.get(identifier=&#039;claude-api-token&#039;)&lt;br /&gt;
print(f&#039;Token: {t.identifier}&#039;)&lt;br /&gt;
print(f&#039;Expires: {t.expires}&#039;)&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If a token expires, create a new one in the Admin UI (User → Tokens) and update all scripts/services using it.&lt;br /&gt;
&lt;br /&gt;
=== 8. LDAP Bind Accounts Must Be in ldap-search Group ===&lt;br /&gt;
&lt;br /&gt;
Any user/service that needs to query the LDAP directory must be a member of the &#039;&#039;&#039;&amp;lt;code&amp;gt;ldap-search&amp;lt;/code&amp;gt;&#039;&#039;&#039; group. Currently:&lt;br /&gt;
* &amp;lt;code&amp;gt;stalwart-svc&amp;lt;/code&amp;gt; — for mail server LDAP lookups&lt;br /&gt;
* &amp;lt;code&amp;gt;nextcloud-svc&amp;lt;/code&amp;gt; — for CalDAV/CardDAV user sync&lt;br /&gt;
&lt;br /&gt;
Without group membership, LDAP bind fails with &amp;quot;Access Denied&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Verification Checklist ==&lt;br /&gt;
&lt;br /&gt;
After recovery, verify:&lt;br /&gt;
&lt;br /&gt;
* [ ] &#039;&#039;&#039;Admin UI accessible:&#039;&#039;&#039; &amp;lt;code&amp;gt;https://key.wilsoz.com/if/admin/&amp;lt;/code&amp;gt; → login as marcus&lt;br /&gt;
* [ ] &#039;&#039;&#039;OAuth discovery endpoint working:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  curl -s https://key.wilsoz.com/application/o/immich/.well-known/openid-configuration | jq &#039;.scopes_supported&#039;&lt;br /&gt;
  # Should include: [&amp;quot;openid&amp;quot;, &amp;quot;email&amp;quot;, &amp;quot;profile&amp;quot;]&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;JWKS endpoint has keys:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  curl -s https://key.wilsoz.com/application/o/immich/jwks/ | jq &#039;.keys | length&#039;&lt;br /&gt;
  # Should return a number &amp;gt; 0&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;LDAP directory responding:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  ldapsearch -H ldap://172.17.0.1:389 -D &amp;quot;cn=stalwart-svc,ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; \&lt;br /&gt;
    -w &amp;lt;stalwart-svc-password&amp;gt; -b &amp;quot;ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; &amp;quot;uid=*&amp;quot; | grep &amp;quot;numResponses&amp;quot;&lt;br /&gt;
  # Should return matches (or at least show successful bind)&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Users present:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker exec authentik-app_server_1 ak shell -c &amp;quot;from authentik.core.models import User; print(&#039;\n&#039;.join([u.username for u in User.objects.all()]))&amp;quot;&lt;br /&gt;
  # Should show: akadmin, marcus, stalwart-svc, nextcloud-svc&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Groups present:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker exec authentik-app_server_1 ak shell -c &amp;quot;from authentik.core.models import Group; print(&#039;\n&#039;.join([g.name for g in Group.objects.all()]))&amp;quot;&lt;br /&gt;
  # Should show all configured groups&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;OAuth providers have signing keys:&#039;&#039;&#039;&lt;br /&gt;
  ```bash&lt;br /&gt;
  docker exec authentik-app_server_1 ak shell -c &amp;quot;&lt;br /&gt;
  from authentik.providers.oauth2.models import OAuth2Provider&lt;br /&gt;
  for p in OAuth2Provider.objects.all():&lt;br /&gt;
    has_key = p.signing_key is not None&lt;br /&gt;
    print(f&#039;{p.name}: signing_key={has_key}&#039;)&lt;br /&gt;
  &amp;quot;&lt;br /&gt;
  # All should show: signing_key=True&lt;br /&gt;
  ```&lt;br /&gt;
* [ ] &#039;&#039;&#039;Dependent services can authenticate:&#039;&#039;&#039;&lt;br /&gt;
** [ ] Immich: Try OAuth login at https://photos.wilsoz.com&lt;br /&gt;
** [ ] Linkwarden: Try OAuth login at https://bookmarks.wilsoz.com&lt;br /&gt;
** [ ] Memos: Try OAuth login at https://notes.wilsoz.com&lt;br /&gt;
** [ ] Stalwart IMAP: Test LDAP lookup for mail routing&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Dependencies ==&lt;br /&gt;
&lt;br /&gt;
=== What Authentik Depends On ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL 16&#039;&#039;&#039; — must be healthy before server/worker start&lt;br /&gt;
* &#039;&#039;&#039;Docker&#039;&#039;&#039; — for container runtime&lt;br /&gt;
* &#039;&#039;&#039;Network access to Cloudflare DNS&#039;&#039;&#039; — for DNS resolution of &amp;lt;code&amp;gt;key.wilsoz.com&amp;lt;/code&amp;gt;&lt;br /&gt;
* &#039;&#039;&#039;VPS reverse proxy&#039;&#039;&#039; — nginx on VPS must forward &amp;lt;code&amp;gt;key.wilsoz.com:443&amp;lt;/code&amp;gt; to &amp;lt;code&amp;gt;stronghold:9000&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== What Depends On Authentik ===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;All OAuth2 consumers:&#039;&#039;&#039; Immich, Memos, Linkwarden, Forgejo, Vaultwarden, Grafana, n8n, Matrix&lt;br /&gt;
* &#039;&#039;&#039;All LDAP consumers:&#039;&#039;&#039; Nextcloud, Jellyfin, Stalwart (mail server)&lt;br /&gt;
* &#039;&#039;&#039;Cloudflare Access:&#039;&#039;&#039; For gating n8n form URLs&lt;br /&gt;
* &#039;&#039;&#039;Email provisioning workflow (n8n):&#039;&#039;&#039; Watches Authentik groups for email user provisioning&lt;br /&gt;
* &#039;&#039;&#039;Nginx reverse proxy:&#039;&#039;&#039; Serves key.wilsoz.com → Authentik&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Operations ==&lt;br /&gt;
&lt;br /&gt;
=== Start/Stop/Restart ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /library/authentik-app&lt;br /&gt;
&lt;br /&gt;
# Start&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
# Stop&lt;br /&gt;
docker compose down&lt;br /&gt;
&lt;br /&gt;
# Restart&lt;br /&gt;
docker compose restart server worker&lt;br /&gt;
&lt;br /&gt;
# Full restart (clears caches)&lt;br /&gt;
docker compose down &amp;amp;&amp;amp; sleep 2 &amp;amp;&amp;amp; docker compose up -d&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== View Logs ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Server logs&lt;br /&gt;
docker logs -f server&lt;br /&gt;
&lt;br /&gt;
# Worker logs (for background tasks)&lt;br /&gt;
docker logs -f worker&lt;br /&gt;
&lt;br /&gt;
# PostgreSQL logs (for DB issues)&lt;br /&gt;
docker logs postgresql&lt;br /&gt;
&lt;br /&gt;
# All together&lt;br /&gt;
docker compose logs -f&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Access Django Shell (Advanced) ===&lt;br /&gt;
&lt;br /&gt;
For one-off admin tasks:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker exec authentik-app_server_1 ak shell&lt;br /&gt;
&lt;br /&gt;
# Example: List all users&lt;br /&gt;
from authentik.core.models import User&lt;br /&gt;
for u in User.objects.all():&lt;br /&gt;
    print(f&amp;quot;{u.username}: {u.email}, groups: {list(u.groups.values_list(&#039;name&#039;, flat=True))}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
# Example: Create API token&lt;br /&gt;
from authentik.core.models import Token&lt;br /&gt;
t = Token.objects.create(user=User.objects.get(username=&#039;marcus&#039;), identifier=&#039;my-api-key&#039;)&lt;br /&gt;
print(f&amp;quot;Token: {t.key}&amp;quot;)&lt;br /&gt;
&lt;br /&gt;
# Exit: Ctrl+D&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Backup PostgreSQL Manually ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Full database dump&lt;br /&gt;
docker exec authentik-app_postgresql_1 pg_dump -U authentik authentik | \&lt;br /&gt;
  gzip &amp;gt; /home/mnw/backups/authentik-manual-$(date +%Y%m%d-%H%M%S).sql.gz&lt;br /&gt;
&lt;br /&gt;
# Or via Restic (for Hetzner backup)&lt;br /&gt;
cd /library/backup-stack &amp;amp;&amp;amp; docker compose exec restic-backup bash -c &amp;quot;&lt;br /&gt;
  export RESTIC_REPOSITORY=\$HETZNER_REPO&lt;br /&gt;
  export RESTIC_PASSWORD=\$HETZNER_PASSWORD&lt;br /&gt;
  # Make dump available in /data&lt;br /&gt;
  docker exec authentik-app_postgresql_1 pg_dump -U authentik authentik | gzip &amp;gt; /data/dumps/authentik-manual.sql.gz&lt;br /&gt;
  restic backup /data/dumps/&lt;br /&gt;
&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Troubleshooting ==&lt;br /&gt;
&lt;br /&gt;
=== Can&#039;t log into admin UI ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; 403 Forbidden, or password rejected&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fixes:&#039;&#039;&#039;&lt;br /&gt;
# Check if server is running: &amp;lt;code&amp;gt;docker ps | grep server&amp;lt;/code&amp;gt;&lt;br /&gt;
# Reset akadmin password via Django shell (see Operations section)&lt;br /&gt;
# Check logs: &amp;lt;code&amp;gt;docker logs server | grep -i &amp;quot;error\|auth&amp;quot;&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== OAuth login fails with &amp;quot;invalid_grant&amp;quot; ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; Client authentication failed, or code exchange error&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Causes &amp;amp; fixes:&#039;&#039;&#039;&lt;br /&gt;
* Client secret mismatch: Verify in Authentik provider matches app&#039;s .env&lt;br /&gt;
* Provider deleted: Recreate OAuth provider and update app config&lt;br /&gt;
* Redirect URI mismatch: Ensure app&#039;s callback URL matches provider&#039;s redirect URIs exactly&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Debug:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker logs server | grep -i &amp;quot;oauth\|invalid_grant&amp;quot;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== LDAP bind fails ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; Stalwart/Nextcloud can&#039;t authenticate users&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fixes:&#039;&#039;&#039;&lt;br /&gt;
# Verify LDAP outpost is running: &amp;lt;code&amp;gt;docker ps | grep outposts&amp;lt;/code&amp;gt;&lt;br /&gt;
# Check user is in &amp;lt;code&amp;gt;ldap-search&amp;lt;/code&amp;gt; group: Admin UI → Directory → Groups → ldap-search&lt;br /&gt;
# Test bind from stronghold:&lt;br /&gt;
   ```bash&lt;br /&gt;
   ldapsearch -H ldap://172.17.0.1:389 -D &amp;quot;cn=stalwart-svc,ou=users,dc=ldap,dc=goauthentik,dc=io&amp;quot; \&lt;br /&gt;
     -w &amp;lt;password&amp;gt; -b &amp;quot;ou=users&amp;quot; &amp;quot;uid=marcus&amp;quot;&lt;br /&gt;
   ```&lt;br /&gt;
&lt;br /&gt;
=== Worker not picking up changes ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; Group membership not reflected, or other background tasks stuck&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039;&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker compose restart worker&lt;br /&gt;
docker logs worker --tail=50&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Database corruption ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Symptoms:&#039;&#039;&#039; PostgreSQL won&#039;t start, or &amp;quot;ERROR: relation does not exist&amp;quot;&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Fix:&#039;&#039;&#039;&lt;br /&gt;
* Try recovery from backup (Disaster Mode B)&lt;br /&gt;
* If no backup: full reset (Disaster Mode A) — all data lost&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Links &amp;amp; References ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Authentik Docs:&#039;&#039;&#039; https://goauthentik.io/docs/&lt;br /&gt;
* &#039;&#039;&#039;Authentik Admin UI:&#039;&#039;&#039; https://key.wilsoz.com/if/admin/&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL Docs:&#039;&#039;&#039; https://www.postgresql.org/docs/16/&lt;br /&gt;
* &#039;&#039;&#039;OAuth 2.0 Spec:&#039;&#039;&#039; https://datatracker.ietf.org/doc/html/rfc6749&lt;br /&gt;
* &#039;&#039;&#039;OpenID Connect Spec:&#039;&#039;&#039; https://openid.net/connect/&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Related Services ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;OpenBao&#039;&#039;&#039; (secrets store): &amp;lt;code&amp;gt;/library/openbao-app/&amp;lt;/code&amp;gt; — stores API tokens&lt;br /&gt;
* &#039;&#039;&#039;Stalwart&#039;&#039;&#039; (mail server): Uses LDAP for user lookups&lt;br /&gt;
* &#039;&#039;&#039;Nextcloud&#039;&#039;&#039; (file sync): Uses LDAP for CalDAV/CardDAV&lt;br /&gt;
* &#039;&#039;&#039;n8n&#039;&#039;&#039; (automation): Uses OAuth for form access control&lt;br /&gt;
* &#039;&#039;&#039;PostgreSQL&#039;&#039;&#039; (database): Internal to Authentik&lt;br /&gt;
* &#039;&#039;&#039;Nginx&#039;&#039;&#039; (reverse proxy): VPS nginx forwards key.wilsoz.com traffic here&lt;/div&gt;</summary>
		<author><name>Wikibot</name></author>
	</entry>
</feed>