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.
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.comdans 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_RESENDetwiki.exemple.compar 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.0est requis.bind 127.0.0.1n’é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 nonCommand=dans Quadlet —Commandest 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 officieldocker.getoutline.comrequiert 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.
12. Authentification — magic link par email
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.
- Inscrivez-vous sur https://resend.com
- Ajoutez votre domaine sous Domains et renseignez les enregistrements DNS indiqués
- Créez une clé API sous API Keys
- Assurez-vous que
outline.envcontient 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.comsauf si l’adresse correspond à un domaine pré-approuvé.GOOGLE_ALLOWED_DOMAINS=gmail.comdans 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
| Erreur | Cause | Solution |
|---|---|---|
libsubid.so.4: Permission denied | Profil AppArmor cassé | Section 1 — patcher passt/pasta, remplacer les profils |
aa-complain: Operation {'runbindable'} | Règle de montage malformée dans l’abstraction AppArmor | Section 1 — correctif python3 |
unsupported key 'Command' dans Quadlet | Nom de clé incorrect | Utiliser Exec= et non Command= |
Redis ECONNREFUSED depuis l’app | bind 127.0.0.1 dans redis.conf | Changer en bind 0.0.0.0 |
| Erreur Redlock quorum au démarrage | Redis redémarré en cours de migration | Arrêter l’app → arrêter Redis → démarrer Redis → attendre PONG → démarrer l’app |
authentication required au pull de l’image Outline | docker.getoutline.com requiert une auth | Utiliser docker.io/outlinewiki/outline:latest |
sorry, new account cannot be created with personal Gmail | Outline bloque nativement @gmail.com | Utiliser l’auth magic link SMTP |
Caddy permission denied sur le Caddyfile | Mauvais propriétaire du fichier | sudo chown -R caddy:caddy /etc/caddy |
| Erreur GPG Caddy à l’install apt | Échec de la clé Cloudsmith sur aarch64 | Installer le binaire Caddy directement depuis les releases GitHub |
redirect_uri_mismatch avec Google OAuth | URL de callback non enregistrée dans Google Cloud Console | Ajouter 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=localpar un stockage compatible S3. - Mises à jour automatiques des images — ajoutez
AutoUpdate=registry(déjà dans les fichiers d’unités) et activezpodman-auto-update.timerpour des mises à jour sans intervention. - Monitoring — redirigez
journalctl _UID=$(id -u outline)vers votre agrégateur de logs préféré.