SSD Nodes Learn 🎉 VPS dès $5.50/mois
Guides Matt ConnorPar Matt Connor · Mis à jour le 2026-08-15

Auto-héberger LinkBreeze avec Docker et Caddy

Déployez LinkBreeze sur un VPS avec Docker Compose et Caddy : tags d’image épinglés, suivi des clics sans cookies et un seul volume pour tout le site.

Ce qu’est LinkBreeze

LinkBreeze est une alternative auto-hébergée à Linktree : un conteneur Docker qui sert une page publique de liens dans la bio et un tableau de bord d’administration, avec toutes les données d’état dans un seul fichier SQLite. Le projet est distribué sous licence MIT, écrit en TypeScript avec Next.js et publié sous la forme de ghcr.io/manak-hash/linkbreeze. Pour l’exécuter, vous avez besoin d’un VPS, d’un domaine avec un enregistrement A pointant vers ce VPS, des ports 80 et 443 ouverts, ainsi que de Docker Engine avec le plugin Compose.

Ce guide couvre le déploiement réellement pris en charge par le dépôt : Docker Compose derrière un reverse proxy qui récupère lui-même ses certificats. Il explique également ce qui peut tomber en panne, car un lien dans une bio est une URL publique sur laquelle d’autres personnes cliquent, et un lien cassé vous fait perdre ce clic.

Avant toute chose, vous devez comprendre à quel point ce projet est récent.

LinkBreeze est-il suffisamment mature pour un lien de profil public ?

En août 2026, le dépôt compte 178 stars, 17 forks et un seul mainteneur. La première release taguée, v1.0.0, date du 1 juillet 2026. Ce projet existe depuis quelques semaines, pas depuis quelques années.

ChartLinkBreeze tagged releases per week, v1.0.0 to v1.2.7
The data behind this chart
[
  {
    "week": "2026-06-29",
    "releases": 3,
    "cumulative": 3
  },
  {
    "week": "2026-07-06",
    "releases": 3,
    "cumulative": 6
  },
  {
    "week": "2026-07-13",
    "releases": 1,
    "cumulative": 7
  },
  {
    "week": "2026-07-20",
    "releases": 2,
    "cumulative": 9
  },
  {
    "week": "2026-07-27",
    "releases": 3,
    "cumulative": 12
  },
  {
    "week": "2026-08-03",
    "releases": 2,
    "cumulative": 14
  },
  {
    "week": "2026-08-10",
    "releases": 3,
    "cumulative": 17
  }
]

Depuis v1.0.0, le projet a publié 17 releases taguées sur 7 semaines civiles. La dernière semaine de ce graphique n’était pas encore terminée au moment de la rédaction de ce guide et en comptait déjà 3.

Considérez ces éléments séparément. Le mainteneur est actif et les bugs sont corrigés en quelques jours. Le schéma et les valeurs par défaut évoluent également encore. Une instance que vous déployez puis que vous oubliez peut donc s’éloigner fortement du code en cours de développement.

La licence vous protège contre le pire scénario. Une licence MIT, une image de conteneur et un fichier SQLite sur votre propre disque signifient que, si le développement s’arrête, votre instance continue de fonctionner. En revanche, la licence ne vous protège pas contre une application web exposée publiquement qui ne reçoit plus de correctifs de sécurité et devient progressivement une liability. Déployez-la en prévoyant de la mettre à jour régulièrement, et faites fonctionner la routine de sauvegarde ci-dessous dès le premier jour.

Épinglez le tag de l’image et n’utilisez pas latest

Le workflow de release pousse exactement deux tags par version : latest et le numéro de version sans le préfixe v. Le tag à utiliser pour la release v1.2.7 est donc ghcr.io/manak-hash/linkbreeze:1.2.7. Écrire :v1.2.7 ne télécharge aucune image et Docker affiche manifest unknown, car ce tag n’a jamais été poussé.

Épinglez le tag, car latest évolue. Selon la cadence indiquée dans le tableau ci-dessus, un docker compose pull vers latest constitue une mise à niveau non révisée d’une page utilisée par votre audience. Avec un tag épinglé, la mise à niveau a lieu lorsque vous modifiez le fichier.

Un dernier point concernant l’image. Le workflow de release effectue la build sans paramètre platforms:. L’image publiée est donc uniquement linux/amd64. Sur un hôte arm64, le pull échoue avec no matching manifest for linux/arm64/v8 in the manifest list entries. Si vous utilisez un VPS ARM plutôt qu’un hôte x86, construisez l’image directement sur le serveur :

git clone --branch v1.2.7 --depth 1 https://github.com/Manak-hash/LinkBreeze.git
cd LinkBreeze
docker build -t linkbreeze:1.2.7 .

Utilisez ensuite linkbreeze:1.2.7 comme nom d’image dans le fichier compose ci-dessous.

Déployer LinkBreeze derrière Caddy avec TLS automatique

Caddy demande et renouvelle lui-même les certificats auprès de Let’s Encrypt. TLS (sécurité de la couche transport) ne nécessite donc aucune étape séparée de gestion des certificats. Le déploiement tient dans trois fichiers placés dans un même répertoire.

Générez d’abord le secret :

mkdir -p ~/linkbreeze && cd ~/linkbreeze
printf 'SECRET_KEY=%s\n' "$(openssl rand -hex 32)" > .env
chmod 600 .env

SECRET_KEY signe le cookie de session d’administration et ajoute un sel au hash des visiteurs pour les analytics. Le fichier Compose publié dans le dépôt lui attribue par défaut la valeur ${SECRET_KEY:-changeme-in-production}. Si vous ignorez cette étape, l’instance utilise donc une clé de signature de session publiée sur GitHub. Définissez-la avant le premier démarrage, car sa modification ultérieure vous déconnecte et réinitialise le sel des analytics.

Écrivez docker-compose.yml :

services:
  linkbreeze:
    image: ghcr.io/manak-hash/linkbreeze:1.2.7
    restart: unless-stopped
    volumes:
      - linkbreeze-data:/app/data
    environment:
      - DATABASE_PATH=/app/data/linkbreeze.db
      - SECRET_KEY=${SECRET_KEY}
      - BASE_URL=https://links.example.com
    networks:
      - linkbreeze-net

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    networks:
      - linkbreeze-net

networks:
  linkbreeze-net:

volumes:
  linkbreeze-data:
  caddy-data:
  caddy-config:

BASE_URL est facultatif, mais il est recommandé de le définir. Il indique à l’application sa véritable adresse publique. Ainsi, une requête contenant un en-tête Host falsifié ne peut pas amener l’application à générer des liens vers le domaine d’un tiers.

Écrivez ensuite Caddyfile à côté, avec votre propre domaine :

links.example.com {
    encode zstd gzip
    reverse_proxy linkbreeze:3000
}

Caddy définit par défaut X-Forwarded-For et X-Forwarded-Proto sur les requêtes transmises par le proxy. Les analytics en dépendent. Démarrez les conteneurs :

docker compose up -d
docker compose ps
docker compose logs -f caddy

docker compose ps doit afficher le conteneur LinkBreeze avec l’état healthy. L’image fournit son propre healthcheck, wget --spider -q http://127.0.0.1:3000/api/health. Vous n’avez donc pas besoin d’en ajouter un. Ne copiez pas le healthcheck de l’exemple Caddy fourni dans le dépôt. Il appelle curl, tandis que l’image repose sur node:22-alpine, qui contient busybox wget mais aucun curl. Le conteneur signale alors unhealthy, même si les pages sont parfaitement accessibles.

Ouvrez https://links.example.com dans un navigateur. La première visite mène à l’assistant de configuration à l’adresse /setup, qui crée l’unique compte d’administration. Ensuite, le dashboard est accessible à /dashboard et le formulaire de connexion à /login. Ce compte est local à cette instance. L’application ne fournit aucun mécanisme de single sign-on. Si vous voulez que le dashboard utilise la même authentification que les autres services hébergés, vous devez placer devant lui un proxy forward auth, tel que un Authentik auto-hébergé.

Notez ce que le fichier Compose ne fait pas : il ne publie jamais le port 3000. Seul Caddy écoute sur l’interface publique. Si la syntaxe des fichiers Compose vous est nouvelle, les bases de Docker Compose pour un VPS couvre les éléments utilisés par ce fichier. Si vous utilisez déjà un autre composant en frontal, comparaison de Nginx, Caddy et Traefik explique les changements nécessaires. Le dépôt fournit des exemples fonctionnels pour Nginx avec Certbot, Traefik et un tunnel Cloudflare.

Emplacement des données et contenu d’une sauvegarde

DATABASE_PATH pointe vers /app/data/linkbreeze.db. Les avatars téléversés et les vignettes des liens sont écrits à côté, dans /app/data/uploads. Les deux se trouvent dans le volume nommé linkbreeze-data. L’unité de sauvegarde est donc le volume, et non le fichier de base de données seul. Restaurez le fichier sans le répertoire des téléversements et chaque image de la page renverra une erreur 404.

Tout le reste se trouve bien dans cette base de données : pages, liens, paramètres, thème, abonnés aux e-mails et lignes d’analytics.

Effectuez la copie lorsque le conteneur est arrêté :

docker compose stop linkbreeze
docker compose cp linkbreeze:/app/data ./backup-$(date +%F)
docker compose start linkbreeze

Arrêtez d’abord le conteneur, car la copie d’une base SQLite pendant qu’un processus y écrit peut capturer une transaction incomplète. La copie s’ouvrira alors comme un fichier corrompu. La page est indisponible pendant la copie. La restauration s’effectue de la même manière, en sens inverse :

docker compose stop linkbreeze
docker compose cp ./backup-2026-08-14/. linkbreeze:/app/data
docker compose start linkbreeze
docker compose logs -f linkbreeze

Le dashboard propose également un export JSON, servi depuis /api/backup sous linkbreeze-backup-YYYY-MM-DD.json. Il contient le profil, les liens, les paramètres et les thèmes enregistrés. Il ne contient pas l’historique des analytics, les abonnés aux e-mails ni les images téléversées. Lors de sa restauration, les lignes actuelles de ces quatre tables sont supprimées avant l’insertion de celles du fichier. Considérez-le comme un snapshot de configuration pour migrer vers un autre hôte ou annuler une erreur de modification. La copie du volume est la sauvegarde.

Deux règles de stockage s’appliquent ici comme ailleurs lorsque vous exécutez SQLite en production sur un VPS. Conservez la base de données sur un disque local, car le verrouillage de SQLite n’est pas fiable sur un système de fichiers réseau et une page corrompue suffit pour s’en rendre compte. Si vous remplacez le volume nommé par un bind mount de l’hôte, faites d’abord un chown du répertoire hôte : le conteneur s’exécute avec l’utilisateur non-root node, dont l’UID est 1000 dans node:22-alpine. Un répertoire créé par root ne peut pas être modifié par cet utilisateur. L’application ne peut donc pas ouvrir la base de données et le conteneur s’arrête au démarrage. Les bind mounts face aux volumes nommés dans Compose présente ce compromis en détail.

Les analytics et la bannière de consentement dont vous n’avez pas besoin

C’est la fonctionnalité qui justifie l’auto-hébergement d’une page que vous pourriez obtenir gratuitement ailleurs.

Les analytics fonctionnent sans cookie. Aucun cookie n’est défini pour un visiteur et aucun script tiers n’est chargé sur la page publique. Un visiteur est identifié par un hash SHA-256 de l’adresse IP, de la chaîne user agent et d’un salt, tronqué à 16 caractères hexadécimaux. Le salt est lui-même un hash de la date UTC courante et de votre SECRET_KEY. Il change donc à minuit UTC, et les hash d’hier ne peuvent pas être mis en correspondance avec ceux d’aujourd’hui. L’adresse IP brute n’est jamais écrite dans la base de données.

Les clics sont comptabilisés sur le serveur. Chaque lien http de la page publique pointe vers /go/<id> sur votre propre domaine. Cette route enregistre le clic, puis répond par une redirection 302 vers la destination réelle. Le comptage fonctionne donc pour les lecteurs qui ont désactivé JavaScript et dans les navigateurs intégrés aux applications qui bloquent les requêtes en arrière-plan. Les pages vues sont enregistrées via /api/track.

Deux exclusions sont à connaître. Une requête qui contient une session admin valide est ignorée. La consultation de votre propre page pendant sa modification n’augmente donc pas les chiffres. Les user agents des crawlers connus sont également ignorés.

Concernant le consentement : rien n’est stocké sur l’appareil du lecteur, et un cookie stocké sur cet appareil est précisément ce pour quoi une bannière de consentement demande une autorisation. Vos obligations dépendent néanmoins du lieu où résident vos lecteurs. Vérifiez-les, mais il n’y a ici aucun cookie de tracking à déclarer et aucun tiers ne reçoit les données.

Un point peut surprendre : si vous faites tourner SECRET_KEY, le salt quotidien change avec lui. Tous les visiteurs qui reviennent sont donc comptés comme nouveaux à partir de ce moment.

Pourquoi la colonne du pays est-elle vide ?

Parce qu’aucun élément de votre stack ne définit d’en-tête de pays. LinkBreeze déduit le pays à partir d’en-têtes de proxy tels que cf-ipcountry et x-vercel-ip-country. Sur un VPS derrière votre propre Caddy ou Nginx, aucun de ces en-têtes n’existe. Le pays est donc enregistré comme null et la ventilation reste vide. Le conteneur n’intègre aucune base de données GeoIP.

Deux solutions permettent de la remplir. Placez Cloudflare devant le domaine. Il ajoute cf-ipcountry à chaque requête qu’il relaie. Vous pouvez aussi définir l’un de ces en-têtes dans votre propre reverse proxy à partir d’une recherche GeoIP locale.

Le problème associé est plus grave. Vérifiez-le également. Les handlers de clic et de vue lisent d’abord l’adresse client dans X-Forwarded-For, puis dans X-Real-IP. Si aucun de ces en-têtes n’est présent, ils utilisent 0.0.0.0. Si vous exposez directement le port 3000 sur Internet sans proxy en amont, tous les visiteurs sont associés à la même valeur de hash. Le nombre de visiteurs uniques reste donc définitivement à 1, et la limite de débit par IP de 60 événements par minute s’applique à toute votre audience en même temps. Derrière la directive reverse_proxy ci-dessus, Caddy définit l’en-tête automatiquement et ces deux problèmes disparaissent.

Importer depuis Linktree : ce qui est récupéré et ce qui ne l’est pas

L’assistant de migration du dashboard accepte l’URL d’un profil public ou un fichier exporté. Il reconnaît les pages linktr.ee, bento.me, lnk.bio, tap.link, hopp.bio, beacons.ai, solo.to, linkfly, mssg.me et LittleLink, ainsi que les exports HTML et JSON génériques. Pour une URL Linktree ou Bento, il lit le JSON __NEXT_DATA__ intégré à ces pages. Pour une page statique, il lit les balises d’ancrage.

Sont récupérés le titre, l’URL, la description et l’image de chaque lien, le fait que le lien soit ou non un profil social, ainsi que votre nom d’affichage, votre bio et votre avatar. Vous choisissez les liens trouvés à conserver avant toute écriture dans la base de données.

Ne sont pas récupérés l’historique des analytics, le thème et la mise en page, les abonnés par e-mail, les dates de publication planifiées, ni les éléments que l’ancienne plateforme garde derrière sa propre connexion. Prévoyez de reconstruire l’apparence manuellement. L’historique des clics reste sur l’ancien service.

L’importateur récupère l’URL depuis votre serveur et non depuis votre navigateur. Il refuse donc les adresses qui ne sont pas publiques. Private/local URLs are not allowed signifie que vous lui avez fourni une adresse située dans votre propre réseau. Ce refus est volontaire : sans cette protection, toute personne ayant accès au dashboard pourrait utiliser votre serveur pour sonder des machines que lui seul peut atteindre. Les autres messages que vous pouvez voir sont Only http and https URLs are allowed, Request timed out et Response too large.

Le scraping dépend du markup d’un service tiers. Si l’assistant ne trouve aucun élément sur une page qui contient clairement des liens, cette plateforme a modifié son HTML depuis l’écriture du parser. Ajoutez les liens manuellement au lieu d’attendre un correctif. Si vous cherchez en réalité des liens courts mesurables plutôt qu’une page de profil, un raccourcisseur d’URL auto-hébergé comme Shlink convient et fonctionne très bien sur le même serveur.

Mise à jour d’un déploiement épinglé

# edit the image tag in docker-compose.yml, then
docker compose pull
docker compose up -d
docker compose logs -f linkbreeze

Les migrations de schéma s’exécutent automatiquement au démarrage du conteneur. Il n’existe aucune méthode documentée pour les annuler. Créez donc d’abord une copie du volume. Une mise à niveau que vous ne pouvez pas annuler n’est sûre que si vous pouvez restaurer l’état précédent.

Le dashboard affiche une bannière lorsqu’une nouvelle release est disponible. Il vérifie cette information en récupérant une petite version du fichier depuis le dépôt GitHub du projet une fois toutes les 24 heures. Aucune information sur votre instance n’est envoyée. Lisez les notes de release avant de modifier le tag, car, à ce stade du projet, une version mineure peut modifier des valeurs par défaut dont vous dépendez.

Modes d’échec et messages affichés

manifest unknown lors du pull. Le tag utilisé était :v1.2.7. Les tags d’un registre ne contiennent pas v ; utilisez donc :1.2.7.

no matching manifest for linux/arm64/v8 in the manifest list entries. L’image publiée est uniquement disponible pour amd64. Construisez-la sur l’hôte ARM à partir des sources correspondant au tag.

Le conteneur signale unhealthy alors que la page se charge correctement. Un healthcheck défini dans votre fichier compose appelle curl, que l’image ne contient pas. Supprimez-le et laissez le healthcheck wget intégré à l’image s’exécuter.

Caddy renvoie une erreur de certificat ou ne renvoie rien. Vérifiez docker compose logs caddy. Les causes habituelles sont un enregistrement A qui ne pointe pas encore vers ce VPS, ou le port 80 fermé par le pare-feu. Cela bloque le challenge HTTP ACME (environnement de gestion automatique des certificats) utilisé par Caddy pour prouver qu’il contrôle le domaine.

Le nombre de visiteurs uniques reste à 1. Aucun proxy ne définit X-Forwarded-For ; tous les visiteurs produisent donc le même hash.

Le conteneur s’arrête juste après son démarrage alors qu’il fonctionnait hier. Si vous êtes passé d’un volume nommé à un bind mount sur l’hôte, le répertoire de données appartient à root et l’application s’exécute avec l’uid 1000. Elle ne peut donc pas ouvrir le fichier de base de données. sudo chown -R 1000:1000 le répertoire sur l’hôte.

Les requêtes de suivi renvoient HTTP 429. La limitation par IP sur /api/track et /go/<id> a été atteinte. Les visiteurs sont toujours redirigés vers leur destination ; le clic n’est simplement pas comptabilisé.

FAQ

LinkBreeze est-il prêt pour être utilisé comme lien public dans une bio ?

Le projet est encore jeune. En août 2026, le dépôt compte 178 stars, 17 forks et un seul maintainer, et la première release date du 1 juillet 2026. En moyenne, de nouvelles releases sortent plus de deux fois par semaine : les bugs sont donc corrigés rapidement, mais le comportement change aussi rapidement. La licence MIT et le fichier SQLite local vous permettent de conserver une page fonctionnelle même si le développement s’arrête. Toutefois, une application web publique qui ne reçoit plus de correctifs de sécurité devient un risque. Considérez donc LinkBreeze comme un logiciel à maintenir à jour, et non comme un logiciel à installer une seule fois.

Quelle image LinkBreeze dois-je exécuter ?

Utilisez le tag de version, par exemple ghcr.io/manak-hash/linkbreeze:1.2.7, et modifiez-le volontairement. Le workflow de release publie uniquement latest et le numéro de version seul. :v1.2.7 avec le v n’existe donc pas, et Docker renvoie manifest unknown. L’image est uniquement construite pour linux/amd64. Sur un VPS arm64, vous devez donc cloner le tag et effectuer la build localement.

Pourquoi la répartition par pays reste-t-elle vide dans les analytics de LinkBreeze ?

LinkBreeze lit le pays du visiteur dans des en-têtes de proxy tels que cf-ipcountry ou x-vercel-ip-country. Il ne possède pas sa propre base de données GeoIP. Un VPS placé derrière votre propre Caddy ou Nginx ne définit aucun de ces en-têtes. Le pays est donc enregistré avec la valeur null. Placez Cloudflare devant le domaine, ou configurez votre reverse proxy pour définir l’un de ces en-têtes à partir d’une recherche GeoIP locale.

Que dois-je sauvegarder exactement, et comment effectuer une restauration ?

Sauvegardez l’intégralité du volume linkbreeze-data, et pas uniquement le fichier de base de données. /app/data/linkbreeze.db contient tous les liens, pages, paramètres, abonnés et enregistrements d’analytics. /app/data/uploads contient les images d’avatar et de miniature référencées par la page. Arrêtez le conteneur, exécutez docker compose cp linkbreeze:/app/data ./backup-$(date +%F), puis redémarrez-le. Pour restaurer les données, recopiez le répertoire dans le conteneur arrêté, puis démarrez celui-ci. L’export JSON du dashboard est un snapshot de la configuration du profil, des liens, des paramètres et des thèmes. Il ne contient ni les analytics ni les images.

L’import depuis Linktree récupère-t-il mes analytics et mon thème ?

Non. L’assistant de migration lit les titres des liens, les URL, les descriptions et les images de votre ancien profil public, ainsi que votre nom d’affichage, votre bio et votre avatar. L’historique des analytics, le thème, les abonnés par e-mail et les dates de publication programmées ne sont pas transférés. Recréez l’apparence dans l’éditeur de thèmes après l’import. Votre historique de clics restera sur l’ancienne plateforme.

#linkbreeze#linktree-alternative#docker-compose#sqlite#auto-hébergement#analytics