Self-hosting Linux

Installer Outline Wiki sur VPS Hetzner avec Podman (sans Docker)

Auto-héberger Outline Wiki sur VPS Hetzner avec Podman, Quadlet systemd et Caddy. Guide complet avec tous les correctifs AppArmor pour Ubuntu 24.04 aarch64.

Renard Digital

C’est le guide que j’aurais voulu trouver. La documentation officielle d’Outline suppose Docker et un AppArmor coopératif. Sur un VPS Hetzner fraîchement installé sous Ubuntu 24.04 aarch64, ni l’un ni l’autre n’est vrai. Ce qui suit est chaque étape — y compris les pièges — distillée d’une installation réelle.

Stack :

  • Ubuntu 24.04.4 LTS (aarch64 / Ampere)
  • Podman 4.9 avec Quadlet (sans daemon Docker)
  • Caddy 2 comme reverse proxy TLS
  • Resend pour l’email transactionnel (auth magic-link)
  • Outline 1.6.x

Avant de commencer

Il vous faut :

  • Un VPS Hetzner (CAX11 ou supérieur — Outline nécessite ~1 Go de RAM au minimum)
  • Un domaine pointant vers votre VPS (wiki.exemple.com dans ce guide)
  • Un compte Resend (offre gratuite, 3 000 emails/mois)
  • Votre domaine vérifié dans Resend pour envoyer depuis noreply@exemple.com

0. Time for hardening!

Vous retrouverez un petit script de hardening que j’utilise pour mes VPS ubuntu ici, toutes les donnees a remplir sont dans le bloc configuration. Ensuite il suffit de le rendre executable avec chmod +x et de le lancer avec sudo ./harden.sh. Je vous conseille egalement de mettre en place Tailscale pour un acces simplifie a votre/vos VPS et machines, Quick Start ici

1. Corriger AppArmor — le premier piège

Ubuntu 24.04 embarque un profil AppArmor cassé pour Podman. Il empêche le chargement de libsubid.so.4, faisant échouer podman immédiatement avec :

podman: error while loading shared libraries: libsubid.so.4: cannot open shared object file: Permission denied

Passer en mode tolérant via aa-complain /usr/bin/podman ne fonctionne pas non plus :

ERROR: Operation {'runbindable'} cannot have a source. Source = AARE('/')

Ce problème est causé par des règles de montage malformées dans deux fichiers d’abstraction AppArmor. Commencez par les corriger :

# Corriger l'abstraction passt (deux règles incorrectes)
sudo sed -i \
  -e 's|mount options=(rw, runbindable) /,|mount options=(rw, runbindable) -> /,|g' \
  /etc/apparmor.d/abstractions/passt

sudo python3 - <<'EOF'
import re
files = {
    '/etc/apparmor.d/abstractions/passt': [
        (r'mount\s+""\s+->\s+"/tmp/",', 'mount -> "/tmp/",'),
    ],
    '/etc/apparmor.d/abstractions/pasta': [
        (r'mount\s+""\s+->\s+"/proc/",', 'mount -> "/proc/",'),
    ],
}
for path, replacements in files.items():
    text = open(path).read()
    for pattern, replacement in replacements:
        text = re.sub(pattern, replacement, text)
    open(path, 'w').write(text)
    print(f"Patché {path}")
EOF

Déchargez ensuite le profil Podman défaillant directement (les outils aa-* ne fonctionneront qu’après la correction du parser) :

sudo apparmor_parser -R /etc/apparmor.d/podman

Remplacez les quatre profils cassés (podman, unprivileged_userns, slirp4netns, crun) par des profils non confinés :

for profile in podman unprivileged_userns slirp4netns crun; do
  binary=""
  [[ $profile == "podman" ]]       && binary=" /usr/bin/podman"
  [[ $profile == "slirp4netns" ]]  && binary=" /usr/bin/slirp4netns"
  [[ $profile == "crun" ]]         && binary=" /usr/bin/crun"

  sudo tee /etc/apparmor.d/${profile} > /dev/null <<EOF
abi <abi/4.0>,
include <tunables/global>
profile ${profile}${binary} flags=(unconfined) {
  userns,
}
EOF
done

sudo apparmor_parser -r \
  /etc/apparmor.d/podman \
  /etc/apparmor.d/unprivileged_userns \
  /etc/apparmor.d/slirp4netns \
  /etc/apparmor.d/crun

Vérification :

podman --version   # doit s'afficher proprement, sans erreur de permission
podman run --rm hello-world

2. Installer Podman

sudo apt update && sudo apt install -y \
  podman slirp4netns uidmap aardvark-dns fuse-overlayfs

podman info --format '{{.Host.CgroupsVersion}}'
# Attendu : 2

aardvark-dns est indispensable. Il fournit la résolution DNS par nom de conteneur au sein des réseaux Podman. Sans lui, les conteneurs ne peuvent pas se joindre par leur nom.


3. Créer l’utilisateur système outline

Faire tourner les conteneurs sous un utilisateur dédié isole la charge de travail et évite de tout exécuter en root.

sudo useradd \
  --system \
  --create-home \
  --home-dir /opt/outline \
  --shell /sbin/nologin \
  outline

# Permettre aux conteneurs de survivre à la déconnexion / démarrer au boot
sudo loginctl enable-linger outline

# Vérifier les mappages subuid/subgid (requis pour les conteneurs rootless)
grep outline /etc/subuid   # ex. outline:100000:65536
grep outline /etc/subgid

Si absent :

sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 outline

4. Structure des répertoires

sudo mkdir -p /opt/outline/{quadlet,data/storage,data/postgres,data/redis,secrets}
sudo chown -R outline:outline /opt/outline
sudo chmod 700 /opt/outline/secrets

5. Secrets et fichier d’environnement

Générez les deux clés secrètes requises :

SECRET_KEY=$(openssl rand -hex 32)
UTILS_SECRET=$(openssl rand -hex 32)
echo "SECRET_KEY:   $SECRET_KEY"
echo "UTILS_SECRET: $UTILS_SECRET"

Écrivez /opt/outline/secrets/outline.env :

sudo -u outline tee /opt/outline/secrets/outline.env > /dev/null <<EOF
# ── Core ─────────────────────────────────────────────────────────
SECRET_KEY=${SECRET_KEY}
UTILS_SECRET=${UTILS_SECRET}
URL=https://wiki.exemple.com
PORT=3000
FORCE_HTTPS=false
ENABLE_UPDATES=true
WEB_CONCURRENCY=1

# ── Postgres ─────────────────────────────────────────────────────
POSTGRES_USER=outline
POSTGRES_PASSWORD=MOT_DE_PASSE_FORT
POSTGRES_DB=outline

# ── URLs de connexion (noms de conteneurs, pas localhost) ──────
DATABASE_URL=postgres://outline:MOT_DE_PASSE_FORT@outline-postgres/outline?sslmode=disable
REDIS_URL=redis://outline-redis:6379
PGSSLMODE=disable

# ── Stockage fichiers ─────────────────────────────────────────────
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
FILE_STORAGE_UPLOAD_MAX_SIZE=262144000

# ── Email / auth magic-link (Resend) ─────────────────────────────
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
SMTP_USERNAME=resend
SMTP_PASSWORD=re_VOTRE_CLE_API_RESEND
SMTP_FROM_EMAIL=noreply@exemple.com
SMTP_REPLY_EMAIL=noreply@exemple.com
SMTP_SECURE=true

# ── Rate limiting ─────────────────────────────────────────────────
RATE_LIMITER_ENABLED=true
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60

# ── Logs ───────────────────────────────────────────────────────────
LOG_LEVEL=warn
EOF

chmod 600 /opt/outline/secrets/outline.env

Remplacez MOT_DE_PASSE_FORT (les deux occurrences doivent correspondre), re_VOTRE_CLE_API_RESEND et wiki.exemple.com par vos vraies valeurs.


6. Configuration Redis

sudo tee /opt/outline/data/redis/redis.conf > /dev/null <<'EOF'
bind 0.0.0.0
protected-mode no
maxmemory 128mb
maxmemory-policy allkeys-lru
save ""
appendonly no
EOF

sudo chmod 644 /opt/outline/data/redis/redis.conf

bind 0.0.0.0 est requis. bind 127.0.0.1 n’écoute que sur le loopback à l’intérieur du conteneur, rendant Redis inaccessible depuis les autres conteneurs même sur le même réseau Podman.


7. Fichiers d’unités Quadlet

Quadlet est la méthode native de Podman pour faire tourner des conteneurs comme services systemd. Chaque fichier .container, .network et .volume génère un .service.

sudo -u outline mkdir -p /opt/outline/quadlet

Réseau — outline.network

sudo -u outline tee /opt/outline/quadlet/outline.network > /dev/null <<'EOF'
[Unit]
Description=Réseau interne Outline

[Network]
NetworkName=outline-net
Driver=bridge
EOF

Volumes — outline-db.volume et outline-storage.volume

sudo -u outline tee /opt/outline/quadlet/outline-db.volume > /dev/null <<'EOF'
[Volume]
VolumeName=outline-db
Driver=local
Device=/opt/outline/data/postgres
Options=bind
EOF

sudo -u outline tee /opt/outline/quadlet/outline-storage.volume > /dev/null <<'EOF'
[Volume]
VolumeName=outline-storage
Driver=local
Device=/opt/outline/data/storage
Options=bind
EOF

Redis — outline-redis.container

sudo -u outline tee /opt/outline/quadlet/outline-redis.container > /dev/null <<'EOF'
[Unit]
Description=Outline — Redis
After=network-online.target

[Container]
ContainerName=outline-redis
Image=docker.io/library/redis:7-alpine
Network=outline.network
Volume=/opt/outline/data/redis/redis.conf:/redis.conf:ro
Exec=redis-server /redis.conf
HealthCmd=redis-cli ping
HealthInterval=10s
HealthTimeout=5s
HealthRetries=3
AutoUpdate=registry

[Service]
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
EOF

Utilisez Exec= et non Command= dans Quadlet — Command est silencieusement ignoré et le conteneur démarre sans argument, ce qui fait échouer Redis.

Postgres — outline-postgres.container

sudo -u outline tee /opt/outline/quadlet/outline-postgres.container > /dev/null <<'EOF'
[Unit]
Description=Outline — PostgreSQL 16
After=network-online.target

[Container]
ContainerName=outline-postgres
Image=docker.io/library/postgres:16-alpine
Network=outline.network
Volume=outline-db.volume:/var/lib/postgresql/data
EnvironmentFile=/opt/outline/secrets/outline.env
HealthCmd=pg_isready -d outline -U outline
HealthInterval=30s
HealthTimeout=20s
HealthRetries=3
AutoUpdate=registry

[Service]
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
EOF

Application Outline — outline-app.container

sudo -u outline tee /opt/outline/quadlet/outline-app.container > /dev/null <<'EOF'
[Unit]
Description=Outline — Application Wiki
After=outline-postgres.service outline-redis.service
Requires=outline-postgres.service outline-redis.service

[Container]
ContainerName=outline-app
Image=docker.io/outlinewiki/outline:latest
Network=outline.network
PublishPort=127.0.0.1:3000:3000
Volume=outline-storage.volume:/var/lib/outline/data
EnvironmentFile=/opt/outline/secrets/outline.env
AutoUpdate=registry

[Service]
Restart=always
RestartSec=10
TimeoutStartSec=120

[Install]
WantedBy=default.target
EOF

Important : Utilisez docker.io/outlinewiki/outline:latest (Docker Hub). Le registre officiel docker.getoutline.com requiert désormais une authentification.


8. Activer Quadlet

sudo -u outline mkdir -p /opt/outline/.config/containers/systemd

sudo -u outline bash -c '
  for f in /opt/outline/quadlet/*.container \
            /opt/outline/quadlet/*.network \
            /opt/outline/quadlet/*.volume; do
    ln -sfv "$f" ~/.config/containers/systemd/
  done
'

OUTLINE_UID=$(id -u outline)
D="XDG_RUNTIME_DIR=/run/user/${OUTLINE_UID}"

sudo -u outline env $D systemctl --user daemon-reload

# Vérifier que Quadlet a généré tous les services
sudo -u outline bash -c \
  "ls /run/user/$(id -u outline)/systemd/generator/ | grep outline"

Résultat attendu :

outline-app.service
outline-db-volume.service
outline-network.service
outline-postgres.service
outline-redis.service
outline-storage-volume.service

9. Télécharger les images et démarrer

Tirez les images en premier pour éviter un timeout systemd au premier démarrage :

sudo -u outline podman pull docker.io/library/redis:7-alpine
sudo -u outline podman pull docker.io/library/postgres:16-alpine
sudo -u outline podman pull docker.io/outlinewiki/outline:latest

Démarrez dans l’ordre de dépendance :

OUTLINE_UID=$(id -u outline)
D="XDG_RUNTIME_DIR=/run/user/${OUTLINE_UID}"

sudo -u outline env $D systemctl --user start outline-network.service
sudo -u outline env $D systemctl --user start outline-redis.service
sudo -u outline env $D systemctl --user start outline-postgres.service

# Attendre que postgres soit prêt
sleep 10

sudo -u outline env $D systemctl --user start outline-app.service

Surveillez le démarrage — les migrations tournent au premier boot et prennent ~30 secondes :

sudo journalctl _UID=${OUTLINE_UID} -f \
  | grep -v "health_status\|PODMAN_SYSTEMD\|autoupdate"

Cherchez Backfill complete — c’est le signe que les migrations sont terminées. Puis testez :

curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/
# Attendu : 200

Erreur Redlock quorum ? Cela arrive si Redis a redémarré en cours de migration. Arrêtez l’app, arrêtez Redis, redémarrez Redis proprement, attendez PONG, puis relancez l’app. L’erreur disparaît au prochain démarrage propre.


10. Installer Caddy

Le dépôt APT Cloudsmith peut avoir des problèmes de GPG sur aarch64. Installez directement depuis la release GitHub :

CADDY_VER=$(curl -s https://api.github.com/repos/caddyserver/caddy/releases/latest \
  | grep tag_name | cut -d'"' -f4 | tr -d v)

curl -sLo /tmp/caddy.tar.gz \
  "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VER}/caddy_${CADDY_VER}_linux_arm64.tar.gz"

sudo tar -xzf /tmp/caddy.tar.gz -C /usr/local/bin caddy
sudo chmod +x /usr/local/bin/caddy
caddy version

Créez l’utilisateur Caddy, les répertoires et le service systemd :

sudo groupadd --system caddy 2>/dev/null || true
sudo useradd --system --gid caddy \
  --home /var/lib/caddy --shell /sbin/nologin caddy 2>/dev/null || true

sudo mkdir -p /etc/caddy /var/log/caddy /var/lib/caddy
sudo chown caddy:caddy /etc/caddy /var/log/caddy /var/lib/caddy

sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
wiki.exemple.com {
    reverse_proxy localhost:3000 {
        header_up Host {host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
    request_body {
        max_size 250MB
    }
    encode gzip zstd
}
EOF

sudo tee /etc/systemd/system/caddy.service > /dev/null <<'EOF'
[Unit]
Description=Caddy
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target

[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl enable --now caddy

11. Pare-feu / firewall

Si vous avez installe le script harden.sh, il a egalement installe le pare-feu nftables. Voici concisement comment le configurer pour autoriser les connexions sur les ports web (80 et 443) ainsi que SSH (22)

sudo nano /etc/nftables.conf

#!/usr/sbin/nft -f
# Effacer les règles existantes
flush ruleset
# Définir la table "inet filter" (IPv4 + IPv6)
table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        # Autoriser le trafic local (loopback)
        iif "lo" accept
        # Autoriser les connexions déjà établies
        ct state established,related accept
        # Autoriser ICMP (ping)
        icmp type echo-request accept
        # Autoriser SSH (port 22)
        tcp dport 22 accept
        # Autoriser HTTP (port 80)
        tcp dport 80 accept
        # Autoriser HTTPS (port 443)
        tcp dport 443 accept
        # Bloquer tout le reste
        drop
    }
    chain forward {
        type filter hook forward priority 0; policy drop;
    }
    chain output {
        type filter hook output priority 0; policy accept;
    }
}

Activer et demarrer nftables:

sudo systemctl enable nftables  # Activer au démarrage
sudo systemctl start nftables   # Démarrer maintenant

Verifier les regles

sudo nft list ruleset

Vous pouvez egalement (recommande) configurer un firewall sur Hetzner avec les memes regles et l’attacher a votre instance.

Caddy obtient automatiquement un certificat Let’s Encrypt. Visitez https://wiki.exemple.com — vous devriez voir la page de connexion d’Outline.


Outline nécessite un fournisseur d’authentification. L’option la plus simple pour une instance personnelle ou une petite équipe est le magic link par email via Resend.

  1. Inscrivez-vous sur https://resend.com
  2. Ajoutez votre domaine sous Domains et renseignez les enregistrements DNS indiqués
  3. Créez une clé API sous API Keys
  4. Assurez-vous que outline.env contient le bloc SMTP de l’étape 5, rempli avec votre vraie clé API et l’adresse d’expéditeur vérifiée

Après redémarrage de l’app, la page de connexion affiche un champ email. Saisissez votre adresse, recevez le magic link, c’est fait.

Google OAuth et adresses Gmail personnelles : Outline bloque explicitement les connexions @gmail.com sauf si l’adresse correspond à un domaine pré-approuvé. GOOGLE_ALLOWED_DOMAINS=gmail.com dans le fichier d’env ne contourne pas ce blocage — les domaines autorisés sont stockés par équipe en base de données et se configurent après la première connexion via l’interface Settings. L’auth magic link évite entièrement ce problème.


13. Survivre aux redémarrages

loginctl enable-linger outline (défini à l’étape 3) assure que les unités systemd de l’utilisateur outline démarrent au boot sans session de connexion. Vérification :

loginctl show-user outline | grep Linger
# Attendu : Linger=yes

Testez en redémarrant et en vérifiant que les conteneurs reviennent :

sudo reboot
# attendre ~60 s
sudo -u outline podman ps

14. Commandes utiles au quotidien

# Raccourci (à ajouter dans ~/.bashrc)
OUTLINE_UID=$(id -u outline)
alias outline-ctl='sudo -u outline env XDG_RUNTIME_DIR=/run/user/${OUTLINE_UID} systemctl --user'

# Statut
outline-ctl status outline-app.service
sudo -u outline podman ps

# Logs en direct
sudo journalctl _UID=$(id -u outline) -f \
  | grep -v "health_status\|PODMAN_SYSTEMD"

# Redémarrer tout
outline-ctl restart outline-redis.service
outline-ctl restart outline-postgres.service
outline-ctl restart outline-app.service

# Shell dans le conteneur app
sudo -u outline podman exec -it outline-app sh

# Shell dans postgres
sudo -u outline podman exec -it outline-postgres psql -U outline outline

15. Sauvegardes de la base de données

sudo tee /opt/outline/backup-db.sh > /dev/null <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/opt/outline/backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
podman exec outline-postgres \
  pg_dump -U outline outline \
  | gzip > "${BACKUP_DIR}/outline_${TIMESTAMP}.sql.gz"
find "$BACKUP_DIR" -name "outline_*.sql.gz" -mtime +30 -delete
echo "Sauvegarde : ${BACKUP_DIR}/outline_${TIMESTAMP}.sql.gz"
SCRIPT

sudo chown outline:outline /opt/outline/backup-db.sh
sudo chmod 750 /opt/outline/backup-db.sh

# Cron quotidien à 02h00
sudo -u outline crontab -l 2>/dev/null \
  | { cat; echo "0 2 * * * /opt/outline/backup-db.sh >> /opt/outline/backup.log 2>&1"; } \
  | sudo -u outline crontab -

Référence de dépannage

ErreurCauseSolution
libsubid.so.4: Permission deniedProfil AppArmor casséSection 1 — patcher passt/pasta, remplacer les profils
aa-complain: Operation {'runbindable'}Règle de montage malformée dans l’abstraction AppArmorSection 1 — correctif python3
unsupported key 'Command' dans QuadletNom de clé incorrectUtiliser Exec= et non Command=
Redis ECONNREFUSED depuis l’appbind 127.0.0.1 dans redis.confChanger en bind 0.0.0.0
Erreur Redlock quorum au démarrageRedis redémarré en cours de migrationArrêter l’app → arrêter Redis → démarrer Redis → attendre PONG → démarrer l’app
authentication required au pull de l’image Outlinedocker.getoutline.com requiert une authUtiliser docker.io/outlinewiki/outline:latest
sorry, new account cannot be created with personal GmailOutline bloque nativement @gmail.comUtiliser l’auth magic link SMTP
Caddy permission denied sur le CaddyfileMauvais propriétaire du fichiersudo chown -R caddy:caddy /etc/caddy
Erreur GPG Caddy à l’install aptÉchec de la clé Cloudsmith sur aarch64Installer le binaire Caddy directement depuis les releases GitHub
redirect_uri_mismatch avec Google OAuthURL de callback non enregistrée dans Google Cloud ConsoleAjouter https://wiki.exemple.com/auth/google.callback aux URI de redirection autorisés

Ce qui n’est pas couvert

  • Stockage S3/MinIO — ce guide utilise le stockage local. Pour une instance multi-nœuds ou une grande équipe, remplacez FILE_STORAGE=local par un stockage compatible S3.
  • Mises à jour automatiques des images — ajoutez AutoUpdate=registry (déjà dans les fichiers d’unités) et activez podman-auto-update.timer pour des mises à jour sans intervention.
  • Monitoring — redirigez journalctl _UID=$(id -u outline) vers votre agrégateur de logs préféré.

TAGS

#self-hosting #outline wiki #podman #caddy #ubuntu #hetzner #quadlet #systemd #vps hetzner #wiki auto-hébergé #conteneurs rootless #apparmor #reverse proxy caddy

Besoin d'un site web professionnel ?

Renard Digital vous accompagne de A à Z : site, domaine, email.

Discutons de votre projet