Aller au contenu

Wiki.js 2.x — Déploiement sur VPS avec Caddy et PostgreSQL

Contexte

  • VPS : Hetzner — 178.105.46.57 — domaine tekdhi.dev
  • Caddy : tourne en container Docker (network_mode: host), gère tous les sous-domaines
  • PostgreSQL : container shared-postgres sur le réseau shared_infra
  • Wiki.js installé le 20260517 pour exposer le vault Obsidian de /home/kanmaber/second-memory/wiki

1. Prérequis vérifiés

# Vérifier les containers existants
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'

# Vérifier le réseau shared_infra
docker inspect shared-postgres | python3 -c "import sys,json; d=json.load(sys.stdin)[0]; nets=d['NetworkSettings']['Networks']; print('Network:', list(nets.keys()))"

# Trouver le Caddyfile (Caddy tourne en Docker, pas systemd)
docker inspect caddy | python3 -c "import sys,json; d=json.load(sys.stdin)[0]; [print(m) for m in d['Mounts']]"
# => Caddyfile monté depuis /home/kanmaber/hermes/Caddyfile

Points critiques découverts : - Port 3000 déjà pris par open-webui → utiliser 3010 pour Wiki.js - Caddy tourne avec admin off → pas de caddy reload via API, il faut docker restart caddy - Le Caddyfile est à /home/kanmaber/hermes/Caddyfile


2. Créer la base PostgreSQL dédiée

# Créer user et DB (se connecter avec l'user admin et la DB par défaut)
docker exec shared-postgres psql -U psycho_user -d psychoenLigne -c "CREATE USER wikijs_user WITH PASSWORD 'wikijs_secret2026';"
docker exec shared-postgres psql -U psycho_user -d psychoenLigne -c "CREATE DATABASE wikijs OWNER wikijs_user;"
docker exec shared-postgres psql -U psycho_user -d psychoenLigne -c "GRANT ALL PRIVILEGES ON DATABASE wikijs TO wikijs_user;"

# Vérifier
docker exec shared-postgres psql -U psycho_user -d psychoenLigne -c '\l' | grep wikijs

Credentials PostgreSQL du projet : - User admin : psycho_user / supersecret123 (dans /home/kanmaber/shared_infra/.env) - DB par défaut : psychoenLigne (obligatoire dans -d, sinon erreur "database psycho_user does not exist")


3. docker-compose.yml de Wiki.js

Fichier : /home/kanmaber/second-memory/docker-compose.yml

services:
  wiki:
    image: requarks/wiki:2
    container_name: wikijs
    restart: unless-stopped
    ports:
      - "127.0.0.1:3010:3000"   # port 3000 pris par open-webui → 3010
    volumes:
      - /home/kanmaber/second-memory/wiki:/home/wiki-content:rw
      # ⚠️ JAMAIS monter sur /wiki — c'est le répertoire app de Wiki.js
      # Utiliser /home/wiki-content ou tout autre chemin hors /wiki
    environment:
      - DB_TYPE=postgres
      - DB_HOST=shared-postgres   # nom du container, résolu par shared_infra
      - DB_PORT=5432
      - DB_NAME=wikijs
      - DB_USER=wikijs_user
      - DB_PASS=wikijs_secret2026

networks:
  default:
    external: true
    name: shared_infra   # même réseau que shared-postgres pour le résoudre par nom
cd /home/kanmaber/second-memory && docker compose up -d
docker logs --tail 20 wikijs  # vérifier "Database Connection Successful"

4. Ajouter wiki.tekdhi.dev au Caddyfile

Fichier : /home/kanmaber/hermes/Caddyfile

Bloc à ajouter (Caddy est en network_mode: host → accès direct à 127.0.0.1:3010) :

# --- Wiki.js ---
wiki.tekdhi.dev {
    reverse_proxy 127.0.0.1:3010
}

Méthode (python3 pour préserver l'encodage UTF-8) :

python3 - << 'EOF'
path = '/home/kanmaber/hermes/Caddyfile'
with open(path, 'r') as f:
    content = f.read()
if 'wiki.tekdhi.dev' not in content:
    content += '\n# --- Wiki.js ---\nwiki.tekdhi.dev {\n    reverse_proxy 127.0.0.1:3010\n}\n'
    with open(path, 'w') as f:
        f.write(content)
    print('Ajouté')
else:
    print('Déjà présent')
EOF

Valider et redémarrer Caddy :

docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker restart caddy   # PAS caddy reload — admin est désactivé (admin off)
docker logs --tail 5 caddy  # vérifier "authorization finalized" pour wiki.tekdhi.dev

Caddy obtient le certificat Let's Encrypt automatiquement au premier démarrage.


5. Compléter le setup initial

Ouvrir https://wiki.tekdhi.dev dans le navigateur → assistant de configuration : 1. Créer le compte admin (email + mot de passe) 2. Cliquer Install — Wiki.js initialise la DB PostgreSQL


6. Importer les pages Markdown via GraphQL

Wiki.js stocke les pages en PostgreSQL. Le module disk est en mode push uniquement (Wiki.js → disque, pas l'inverse). Pour importer des fichiers .md existants, utiliser l'API GraphQL.

Script d'import complet

Déposer sur le VPS : /home/kanmaber/import_wiki.py

import subprocess, json, os, re, time, unicodedata

GRAPHQL = 'https://wiki.tekdhi.dev/graphql'
VAULT   = '/home/kanmaber/second-memory/wiki'  # chemin HÔTE (pas chemin conteneur)
EMAIL   = 'bernardkanmdev@gmail.com'
PWD     = 'pacome@09052003FENU'

def gql_raw(payload, token=None):
    headers = ['-H', 'Content-Type: application/json']
    if token:
        headers += ['-H', f'Authorization: Bearer {token}']
    cmd = ['curl', '-s', '-X', 'POST', GRAPHQL] + headers + ['-d', json.dumps(payload)]
    r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
    return json.loads(r.stdout)

def auth():
    r = gql_raw({'query': f'mutation {{ authentication {{ login(username: "{EMAIL}", password: "{PWD}", strategy: "local") {{ responseResult {{ succeeded }} jwt }} }} }}'})
    return r['data']['authentication']['login']['jwt']

def slugify(text):
    text = unicodedata.normalize('NFD', text)
    text = ''.join(c for c in text if unicodedata.category(c) != 'Mn')
    text = re.sub(r'[^\w\s/-]', '', text)
    text = re.sub(r'[\s_]+', '-', text).strip('-')
    return text.lower()

TOKEN = auth()

# Désactiver disk storage AVANT import (sinon EACCES au moment de l'écriture)
gql_raw({
    'query': 'mutation Dis($targets: [StorageTargetInput]!) { storage { updateTargets(targets: $targets) { responseResult { succeeded } } } }',
    'variables': {'targets': [{'key': 'disk', 'isEnabled': False, 'mode': 'push',
        'config': [{'key': 'path', 'value': '"/home/wiki-content"'}, {'key': 'createDailyBackups', 'value': 'false'}]}]}
}, TOKEN)

existing_resp = gql_raw({'query': '{ pages { list { path } } }'}, TOKEN)
existing = {p['path'] for p in existing_resp['data']['pages']['list']}

md_files = []
for root, dirs, files in os.walk(VAULT):
    dirs[:] = sorted(d for d in dirs if not d.startswith('.'))
    for f in sorted(files):
        if f.endswith('.md'):
            md_files.append(os.path.join(root, f))

CREATE = '''
mutation CreatePage($content: String!, $path: String!, $title: String!, $description: String!) {
  pages {
    create(content: $content, description: $description, editor: "markdown",
           isPrivate: false, isPublished: true, locale: "en",
           path: $path, tags: [], title: $title) {
      responseResult { succeeded errorCode message }
      page { path }
    }
  }
}
'''

created = skipped = errors = 0
token_refresh_at = time.time() + 25 * 60

for filepath in md_files:
    if time.time() > token_refresh_at:
        TOKEN = auth()
        token_refresh_at = time.time() + 25 * 60

    rel = os.path.relpath(filepath, VAULT)
    raw_path = rel.replace(os.sep, '/').removesuffix('.md').strip()
    wiki_path = '/'.join(slugify(s) for s in raw_path.split('/'))

    if wiki_path in existing:
        skipped += 1
        continue

    with open(filepath, 'r', encoding='utf-8', errors='replace') as fh:
        content = fh.read()

    m = re.search(r'^#\s+(.+)', content, re.MULTILINE)
    title = m.group(1).strip() if m else os.path.basename(filepath).removesuffix('.md')

    r = gql_raw({'query': CREATE, 'variables': {
        'content': content, 'path': wiki_path, 'title': title, 'description': ''
    }}, TOKEN)

    rr = r.get('data', {}).get('pages', {}).get('create', {}).get('responseResult', {})
    if rr.get('succeeded'):
        print(f'  OK  {wiki_path}'); created += 1
    else:
        print(f'  ERR [{rr.get("errorCode")}] {wiki_path}: {rr.get("message")}'); errors += 1

print(f'\nImport: {created} créées, {skipped} ignorées, {errors} erreurs')

# Réactiver disk storage avec chemin corrigé
gql_raw({
    'query': 'mutation En($targets: [StorageTargetInput]!) { storage { updateTargets(targets: $targets) { responseResult { succeeded } } } }',
    'variables': {'targets': [{'key': 'disk', 'isEnabled': True, 'mode': 'push',
        'config': [{'key': 'path', 'value': '"/home/wiki-content"'}, {'key': 'createDailyBackups', 'value': 'false'}]}]}
}, TOKEN)
print('Disk storage réactivé')
python3 /home/kanmaber/import_wiki.py

7. Pièges et solutions documentés

Piège 1 — Volume /wiki écrase l'application

Wiki.js utilise /wiki comme répertoire racine en interne.
Erreur : Cannot find module '/wiki/server'
Fix : Monter le vault sur /home/wiki-content ou n'importe quel autre chemin ≠ /wiki

Piège 2 — Espace parasite dans le chemin disk storage

Le setup wizard ajoute un espace parasite : /home/wiki-content (avec espace).
Erreur : EACCES: permission denied, mkdir '/home/wiki-content '
Fix : Désactiver le disk storage avant d'importer les pages via GraphQL, le réactiver ensuite avec le bon chemin (sans espace) via storage { updateTargets }.

Piège 3 — psql sans -d échoue

# ✗ Échoue : "database psycho_user does not exist"
docker exec shared-postgres psql -U psycho_user -c "..."
# ✓ Correct
docker exec shared-postgres psql -U psycho_user -d psychoenLigne -c "..."

Piège 4 — Caddy : caddy reload échoue si admin off

# ✗ Échoue : "connection refused" sur port 2019
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
# ✓ Correct
docker restart caddy

Piège 5 — GraphQL : mutation nommée obligatoire avec variables

# ✗ Échoue : "Expected $, found :"
mutation($content: String!) { ... }
# ✓ Correct
mutation CreatePage($content: String!) { ... }

Piège 6 — StorageTargetInput : valeurs config en JSON sérialisé

# ✗ Échoue : "not valid JSON"
{'key': 'path', 'value': '/home/wiki-content'}
# ✓ Correct : la valeur doit être du JSON sérialisé dans une string
{'key': 'path', 'value': '"/home/wiki-content"'}

Piège 7 — Wiki.js est une SPA : curl montre "Welcome"

curl https://wiki.tekdhi.dev retourne <title>Welcome | Wiki.js</title> — c'est le placeholder HTML avant le chargement de Vue.js.
Le vrai contenu est correct, vérifiable via l'API GraphQL :

curl -s -X POST https://wiki.tekdhi.dev/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ pages { single(id:1) { path title content } } }"}'

Piège 8 — disk storage = push uniquement, pas d'import depuis disque

Le module disk ne supporte que push (Wiki.js → disque). Il ne peut pas importer des fichiers existants automatiquement. L'import doit se faire via l'API GraphQL (pages { create }).


8. Commandes de maintenance

# Voir les logs Wiki.js
docker logs -f wikijs

# Redémarrer Wiki.js
docker compose -f /home/kanmaber/second-memory/docker-compose.yml restart

# Mise à jour Wiki.js
docker pull requarks/wiki:2
docker compose -f /home/kanmaber/second-memory/docker-compose.yml up -d

# Réimporter les nouvelles pages du vault
python3 /home/kanmaber/import_wiki.py

# Vérifier le nombre de pages dans Wiki.js
curl -s -X POST https://wiki.tekdhi.dev/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ pages { list { path } } }"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['data']['pages']['list']), 'pages')"

9. État final (20260517)

Composant Détail
URL https://wiki.tekdhi.dev
Port interne 127.0.0.1:3010
DB PostgreSQL wikijs — user wikijs_user
Réseau Docker shared_infra
Vault monté /home/kanmaber/second-memory/wiki/home/wiki-content
Pages importées 60 pages
Page d'accueil home (id=1) — contenu de index.md
Certificat SSL Let's Encrypt auto via Caddy

Piège 9 — Mismatch locale site/pages → page d'accueil affiche "Welcome"

Wiki.js configure la langue du site séparément de la locale des pages créées via API.
Symptôme : page d'accueil affiche deux boutons (créer home / admin) malgré une page home existante.
Cause : le site était en lang: fr (setup wizard), les pages créées via GraphQL avec locale: "en".
getPageFromDb filtre sur localeCode = siteLocale → aucune page trouvée → rendu welcome.
Fix : aligner la langue du site sur la locale des pages via PostgreSQL, puis redémarrer :

docker exec shared-postgres psql -U psycho_user -d wikijs -c \\
  "UPDATE settings SET value = '{\"code\":\"en\",\"autoUpdate\":true,\"namespacing\":false,\"namespaces\":[\"en\"],\"rtl\":false}' WHERE key = 'lang';"
docker restart wikijs
Ou passer locale: "fr" dans toutes les mutations GraphQL de création de pages.