Skip to content

Repository files navigation

Postiz — auto-hébergement

Stack Postiz (publication programmée sur réseaux sociaux) derrière un Traefik existant, avec sauvegarde borgwarehouse. Le code source de Postiz est cloné pour référence dans github/ (gitignoré, jamais modifié ni versionné ici) — ce dépôt ne contient que la configuration de déploiement.

Design complet : docs/superpowers/specs/2026-07-26-postiz-selfhost-design.md (non versionné, voir .gitignore).

Prérequis sur le serveur

  • Docker et le plugin compose, et make.
  • Un Traefik déjà en place, avec un réseau Docker externe nommé frontend et un certresolver nommé myresolver. (Si tes noms diffèrent, adapte les labels et le bloc networks du docker-compose.yml.)
  • Un enregistrement DNS A pointant POSTIZ_DOMAIN vers le serveur.
  • borg installé sur l'hôte, et un serveur borgwarehouse joignable.
  • Vérifier que le port TEMPORAL_UI_PORT (8233 par défaut) n'est pas déjà utilisé par un autre service de cet hôte avant de démarrer temporal-ui (profile debug).

Un make sans argument liste tout ce qu'on peut faire sur cette stack.

Installation

git clone <ce depot> /opt/postiz
cd /opt/postiz

cp env_example .env && chmod 600 .env
openssl rand -hex 32        # pour JWT_SECRET
openssl rand -hex 32        # pour POSTGRES_PASSWORD
nano .env                   # POSTIZ_DOMAIN, secrets, email si utilise

mkdir -p ./data/config ./data/uploads ./data/postgres
sudo chown 999:999 ./data/postgres   # voir note UID plus bas

Avant le tout premier make up, pré-créer les attributs de recherche Temporal dans le bon type (voir la ligne "Elasticsearch" du tableau ci-dessous pour le pourquoi) :

# 1. Temporal seul (Postiz ne doit PAS encore demarrer)
docker compose up -d temporal-postgresql temporal

# 2. Attendre "Search attributes have been added" dans les logs (~20 s)
docker compose logs temporal | grep "Search attributes have been added"

# 3. Creer les deux attributs en type Keyword
#    --entrypoint temporal est INDISPENSABLE : l'entrypoint de l'image
#    admin-tools est "tini -- sleep infinity", qui avale silencieusement la
#    commande passee et reste bloque indefiniment.
#    -T aussi : le service declare tty: true, incompatible avec un pipe.
DBG="docker compose --profile debug run --rm -T --entrypoint temporal temporal-admin-tools"
$DBG operator search-attribute create --address temporal:7233 --namespace default --name postId --type Keyword
$DBG operator search-attribute create --address temporal:7233 --namespace default --name organizationId --type Keyword

# 4. Verifier : les deux doivent apparaitre en "Keyword"
$DBG operator search-attribute list --address temporal:7233 --namespace default | grep -E "postId|organizationId"

Si cette étape est sautée, Postiz créera lui-même ces attributs au premier démarrage mais en type Text, moins adapté à la recherche par égalité exacte dont il se sert pour annuler un post supprimé. Un attribut ne peut plus changer de type après création, d'où l'ordre imposé ici. À l'inverse, si les attributs existent déjà, Postiz les laisse tels quels (vérifié en préprod : ils sont restés Keyword après le démarrage complet).

Puis démarrer le reste :

make up && make logs

Se rendre sur https://<POSTIZ_DOMAIN> pour créer le compte admin — avant de renseigner EMAIL_PROVIDER dans .env (dès qu'un provider email est configuré, l'activation par email devient obligatoire pour les comptes locaux, voir plus bas).

Vérifier que tout est vert :

make ps        # postiz, postiz-postgres, postiz-redis doivent etre "healthy"

Démarrage de l'orchestrator — à lire une fois

Le passage au vert de postiz prend une à deux minutes, c'est normal. À chaque démarrage, l'orchestrator fait compiler par webpack un bundle de 3 Mo par file de tâches de réseau social (~34), séquentiellement, avant d'ouvrir le port que sonde le healthcheck. Le start_period du healthcheck est réglé large en conséquence ; l'interface web, elle, répond bien avant ça.

Pour suivre l'avancement : docker compose exec postiz tail -f /root/.pm2/logs/orchestrator-error.log (webpack y logue chaque bundle, avec le nom de la file de tâches). La ligne Orchestrator health check listening on port 3002 dans orchestrator-out.log marque la fin.

⚠️ Ne jamais faire docker compose up -d (ni make up) pour appliquer un changement de .env sur une stack qui tourne — utiliser make reload. Recréer le conteneur postiz pendant que Postgres/Redis/Temporal tournent déjà déclenche un blocage au démarrage de l'orchestrator : deadlock sur futex au chargement de ses modules natifs. Reproduit 2 fois sur 2 en préprod avec up seul, 0 fois sur 2 avec down puis up. C'est un bug de Postiz, pas de cette configuration ; make reload évite simplement la course en laissant postiz attendre les healthchecks de ses dépendances.

Comment reconnaître ce blocage (utile car il est totalement silencieux) :

Signe Valeur en cas de blocage
make ps postiz reste starting puis passe unhealthy, indéfiniment
docker compose exec postiz ss -tln | grep 3002 rien (le port ne s'ouvre jamais)
docker compose exec postiz pm2 list orchestrator pourtant online, 0 redémarrage, 0 % CPU
orchestrator-out.log s'arrête après la bannière npm, aucune ligne NestJS
orchestrator-error.log aucun bundle webpack compilé

Un pm2 restart orchestrator ne suffit pas toujours à s'en sortir (testé : échec). La parade fiable est make reload. Conséquence importante : un orchestrator bloqué signifie qu'aucun post programmé ne partira, alors que l'interface web reste parfaitement fonctionnelle — d'où l'intérêt de surveiller l'état healthy du conteneur et pas seulement la disponibilité du site.

Puis configurer la sauvegarde : make init (voir scripts/README.md) — la stack doit déjà tourner (make up), les scripts de sauvegarde passent par docker compose exec. Une stack sans sauvegarde n'est pas en production.

Ce qui diffère du compose officiel Postiz (et pourquoi)

Point Ce dépôt Pourquoi
Image postiz Épinglée POSTIZ_VERSION (v2.23.0), jamais :latest Évite qu'un pull de routine saute une version majeure sans prévenir. version.txt du dépôt amont n'est pas fiable (il retarde toujours d'une version ou plus sur le vrai tag git) — se fier uniquement au tag git réel. La stack Temporal (orchestrator, RUN_CRON, /health/status) n'existe qu'à partir des versions 2.x : ne jamais épingler une version antérieure avec ce compose
./data/config, ./data/uploads, ./data/postgres Bind mounts (pas de volumes Docker nommés) Permet la sauvegarde borg de tout le dossier en une archive, et protège ./data/postgres d'un docker compose down -v accidentel
container_name, noms de routers/services Traefik Aucun container_name ; labels Traefik dérivés de ${COMPOSE_PROJECT_NAME} Les noms de conteneurs Docker et les noms de routers/services Traefik sont tous les deux globaux (à l'hôte Docker, et à l'instance Traefik respectivement), pas namespacés par projet — évite les collisions avec d'autres stacks du même serveur
Elasticsearch (Temporal) Retiré (ENABLE_ES=false), visibilité SQL à la place Évite une dépendance à un réglage noyau (vm.max_map_count) et économise ~700 Mo de RAM. Point à surveiller : Postiz recherche les workflows par l'attribut postId (pour annuler un post supprimé) — cet attribut est enregistré par défaut en type Text, qui ne se comporte pas comme un Keyword sur une égalité exacte en visibilité SQL. D'où l'étape "pré-créer les attributs" de l'installation ci-dessus. Après le premier déploiement, supprimer un post programmé de test et confirmer qu'il ne part pas quand même
postiz : depends_on: temporal Ajouté (absent de l'officiel) Sans ça, le backend peut crash-looper (sous pm2) au tout premier démarrage à froid, le temps que Temporal crée son schéma dans une base neuve
temporal-ui, temporal-admin-tools profiles: ["debug"], jamais démarrés par défaut, port loopback uniquement, réseau debug-access dédié Aucun des deux n'a d'authentification native. Le réseau debug-access (non-internal) est nécessaire : un conteneur uniquement rattaché à des réseaux internal: true ne voit pas son port publié sur l'hôte sur les moteurs Docker récents. Démarrage à la demande : docker compose --profile debug up -d temporal-ui puis ssh -L 8233:127.0.0.1:8233 user@serveur (make down arrête aussi ce profile)
RUN_CRON true Filet de rattrapage qui rescanne les posts en retard de moins de 48h — nécessaire puisque la base Temporal n'est pas sauvegardée (régénérable par ailleurs)
Labels Traefik enable/docker.network/tls.certresolver/rule/loadbalancer.server.port Convention déjà en place sur les autres stacks de ce serveur, pas la syntaxe de la doc officielle Postiz

Empreinte mémoire mesurée (préprod, v2.22.1, stack au repos), avec et sans EXCLUDE_QUEUE (voir plus bas) :

Conteneur Sans EXCLUDE_QUEUE Avec (1 seul réseau)
postiz (backend + frontend + orchestrator sous pm2) 2,41 Gio 1,25 Gio
temporal 189 Mio 79 Mio
temporal-postgresql 76 Mio 86 Mio
postiz-postgres 46 Mio 31 Mio
postiz-redis 3 Mio 3 Mio
total stack ~2,7 Gio ~1,45 Gio

Détail par processus dans le conteneur postiz, sans EXCLUDE_QUEUE : orchestrator 1 585 Mo, backend 535 Mo, frontend 228 Mo, plus ~500 Mo d'enveloppes pnpm qui ne font qu'attendre (l'image lance pnpm start au lieu de node directement). Avec EXCLUDE_QUEUE, l'orchestrator tombe à 497 Mo.

Compter donc 2 Go de RAM libre minimum avec EXCLUDE_QUEUE bien réglé, 4 Go sans, et davantage si tu utilises la génération d'images/vidéo. Le conteneur postiz étant le plus gros consommateur de la machine, c'est lui que l'OOM-killer du noyau désignera en cas de saturation — ou l'inverse, sa croissance peut faire tomber un autre service de l'hôte. Aucune limite mem_limit/cpus n'est posée : à ajouter si plusieurs services lourds cohabitent sur le serveur, pour cantonner le dégât à la stack.

EXCLUDE_QUEUE — le principal levier d'empreinte

Non documenté en amont, trouvé dans le code (temporal.module.ts). Par défaut l'orchestrator démarre un worker Temporal par réseau social supporté (~33), qu'il soit connecté ou non — chacun avec son bac à sable de workflow et son bundle webpack de 3 Mo, compilés séquentiellement au démarrage. Exclure les files inutilisées divise l'empreinte par deux et le temps de démarrage aussi (90 s → 40 s, 33 bundles → 2).

⚠️ Panne silencieuse : si un canal est connecté plus tard alors que sa file est exclue, l'interface acceptera de programmer des posts mais aucun worker ne les traitera — ils ne partiront jamais, sans message d'erreur. Mettre la liste à jour à chaque nouveau canal, et ne jamais exclure main (workflows généraux : rattrapage RUN_CRON, emails). Voir env_example pour la liste des files et un exemple.

Vérifier ce qui tourne réellement après un démarrage :

docker compose exec postiz sh -c "grep -oE \"taskQueue: .[a-z]+\" /root/.pm2/logs/orchestrator-error.log | sort -u"

De même, pm2 écrit ses propres logs (~/.pm2/logs/* dans le conteneur postiz) qui ne sont PAS couverts par la limite logging: max-size (celle-ci ne plafonne que le flux stdout/stderr capté par Docker) — à surveiller si le disque du conteneur grossit de façon inattendue.

Le chat IA et la génération d'images ne sont pas désactivables

Il n'existe aucun réglage pour les couper : pas de DISABLE_AI ni équivalent (les seules occurrences de DISABLE_* concernent DISABLE_IMAGE_COMPRESSION, qui porte sur la compression des fichiers envoyés, pas sur la génération). Le code est chargé dans tous les cas — ne pas définir OPENAI_API_KEY rend seulement les fonctions inertes (openai.service.ts retombe sur une clé factice sk-proj-), sans aucun gain de mémoire.

Surtout : ne pas chercher à retirer ces dépendances pour alléger la stack — le serveur MCP en dépend. libraries/nestjs-libraries/src/chat/start.mcp.ts importe MastraService depuis chat/mastra.service et MCPServer depuis @mastra/mcp : le serveur MCP est l'agent Mastra exposé en MCP, c'est le même code. Supprimer la brique de chat casserait le MCP.

À l'inverse, le MCP fonctionne parfaitement sans clé OpenAI (vérifié en préprod) : un serveur MCP expose des outils, il n'appelle aucun LLM lui-même — c'est le client (Claude Code, Claude Desktop…) qui s'en charge. La configuration par défaut de ce dépôt est donc déjà l'état souhaitable : fonctions IA inertes, MCP opérationnel.

Le vrai levier d'empreinte est EXCLUDE_QUEUE (voir plus haut), pas l'IA.

OAuth générique (Keycloak) — reporté

POSTIZ_GENERIC_OAUTH permettrait de déléguer la connexion à un Keycloak existant, mais le provider GENERIC contourne DISABLE_REGISTRATION dans le code Postiz (auth.service.ts:24canRegister renvoie vrai pour ce provider quel que soit le réglage) : n'importe quel compte du realm pourrait alors créer une organisation Postiz. À activer uniquement avec un client Keycloak dédié à accès restreint (rôle/groupe requis) — les variables sont présentes mais commentées dans env_example, et volontairement non câblées dans docker-compose.yml tant que la fonctionnalité est reportée (les décommenter seules dans .env ne suffira pas : il faudra aussi les ajouter au compose).

Sauvegarde et restauration

Voir scripts/README.md pour le détail complet (mise en place, vérification, restauration, rétention).

make init      # une seule fois : configure la sauvegarde (la stack doit tourner)
make backup    # sauvegarde manuelle
make check     # verifie que la derniere sauvegarde est restaurable

Mise à jour

nano .env   # bumper POSTIZ_VERSION vers la nouvelle release
make update

docker compose pull seul ne ramène rien : l'image est épinglée par version exacte, pas :latest. Voir les releases : https://github.com/gitroomhq/postiz-app/releases

make update sauvegarde automatiquement avant de mettre à jour (--no-backup pour sauter cette étape, déconseillé), puis attend que postiz repasse en bonne santé avant de rendre la main (jusqu'à 10 minutes : le healthcheck lui-même peut prendre plusieurs minutes avant de savoir dire "en échec").

En cas d'échec de mise à jour, ne PAS se contenter de redescendre POSTIZ_VERSION vers l'ancien tag. Postiz exécute prisma db push --accept-data-loss à chaque démarrage, pas seulement au premier boot d'une version neuve : la base peut avoir déjà migré vers un schéma que l'ancienne version ne sait plus lire, ou que la redescente re-migrerait de façon destructive. La procédure sûre est de restaurer la base depuis la sauvegarde prise juste avant (voir scripts/README.md), pas de rejouer un ancien tag sur la base telle quelle.

CLI Postiz et skill Claude Code

Le CLI officiel (paquet npm postiz) pilote l'instance par l'API publique /public/v1. Il couvre plus de choses que le serveur MCP intégré : lister et supprimer des posts existants, changer leur statut, lire les analytics, téléverser un fichier local — toutes opérations absentes du MCP. En revanche il ne fait aucune génération d'image ou de vidéo ; ces fonctions-là dépendent de OPENAI_API_KEY, non renseignée sur cette instance, et échouent en 500 aussi bien via le MCP que via l'interface web.

Mise en route

cp env_postiz_cli_example .env.postiz-cli && chmod 600 .env.postiz-cli
nano .env.postiz-cli          # POSTIZ_API_KEY (Settings > Public API) et POSTIZ_API_URL
./scripts/postiz integrations:list

Si la dernière commande renvoie du JSON, tout est en place. Un 401 signale une clé invalide ; du HTML au lieu du JSON signale un POSTIZ_API_URL sans le suffixe /api.

analytics:platform renvoie toujours [] sur cette instance, et ce n'est pas une erreur de configuration : seuls dix providers implémentent la méthode analytics() côté Postiz (Facebook, Instagram, Threads, X, LinkedIn Page, YouTube, TikTok, Pinterest, GMB), et Mastodon n'en fait pas partie — mastodon.provider.ts ne définit pas cette méthode. Rien ne remontera tant qu'un canal analytique ne sera pas connecté.

Pourquoi un wrapper plutôt que npm install -g postiz

./scripts/postiz appelle le CLI via npx à une version épinglée et charge .env.postiz-cli. Rien n'est installé globalement : ce dépôt n'a pas de package.json, et un npm install local y déposerait package.json, package-lock.json et node_modules/ sans rapport avec une stack docker-compose. L'épinglage suit la même logique que POSTIZ_VERSION pour l'image Docker.

Ne jamais lancer postiz auth:login. Cette commande s'authentifie contre le cloud Postiz (cli-auth.postiz.com, api.postiz.com), pas contre cette instance, et écrit ~/.postiz/credentials.json. Or le CLI lit ce fichier en priorité sur POSTIZ_API_KEY et POSTIZ_API_URL (src/config.ts du dépôt postiz-agent) : une fois créé, toutes les commandes partiraient silencieusement vers le cloud. Le wrapper avertit si le fichier est présent, mais ne le supprime pas de lui-même.

Skill Claude Code

.claude/skills/postiz/SKILL.md est le skill officiel, adapté à ce dépôt et versionné à cet endroit (le .gitignore exclut .claude/ sauf skills/). Trois écarts par rapport à l'amont sont documentés en tête du fichier : passage par ./scripts/postiz, interdiction des commandes auth:*, indisponibilité des fonctions IA. Le reste du fichier est le texte amont intact, ce qui permet de rejouer la comparaison lors d'une mise à jour :

curl -s https://raw.githubusercontent.com/gitroomhq/postiz-agent/main/SKILL.md \
  | diff - .claude/skills/postiz/SKILL.md

L'installation passe volontairement par ce fichier plutôt que par npx skills add gitroomhq/postiz-agent : le skill n'est qu'un Markdown, le copier à la main le garde confiné au dépôt, versionné et lisible avant exécution.

Dépannage

postiz reste unhealthymake logs. Causes habituelles : variable manquante dans .env, permissions sur ./data/uploads ou ./data/config (pas de Dockerfile de production disponible pour confirmer l'UID exact de l'image — ajuster l'ownership selon l'erreur observée dans les logs, ou simplement attendre : l'image tourne apparemment en root d'après son Dockerfile de référence, donc l'écriture ne devrait pas être bloquée).

Erreur de connexion à la base — vérifier qu'aucune variable POSTGRES_* n'a été modifiée depuis l'initialisation de la base.

postiz-postgres refuse de démarrer / erreurs de permission sur ./data/postgres — le chown 999:999 de l'installation vise l'UID interne habituel des images postgres récentes ; si ça ne correspond pas exactement à celui de l'image utilisée, sans conséquence grave : l'entrypoint officiel de l'image tourne en root au tout premier démarrage et corrige lui-même la propriété du répertoire de données.

Traefik ne route pas — vérifier que le réseau frontend existe (docker network ls) et que postiz y est bien attaché (docker inspect $(docker compose ps -q postiz)).

Certificat non émis — le DNS doit pointer sur le serveur avant le premier démarrage, sinon le certresolver échoue et retente avec un délai.

Un post supprimé part quand même — signe que la recherche de workflow par postId ne fonctionne pas en visibilité SQL (retrait d'Elasticsearch, voir plus haut) : vérifier d'abord que l'étape de pré-création des attributs en type Keyword a bien été faite avant le premier démarrage de postiz — c'est le risque assumé documenté dans le design.

Compte admin créé mais jamais activéEMAIL_PROVIDER a été rempli avant que le SMTP soit vérifié fonctionnel. Voir la section email de env_example.

Les emails ne partent pas, sans aucune erreur — vérifier d'abord dans les logs quel provider a réellement été sélectionné :

docker compose logs postiz | grep -E "Email service provider|Missing environment variable"

Doit afficher Email service provider: nodemailer. Si c'est empty, EMAIL_PROVIDER ne vaut pas exactement nodemailer ou resendtoute autre valeur (y compris mailgun, smtp, gmail…) retombe silencieusement sur un provider qui n'envoie rien. Mailgun/Brevo/OVH sont des hôtes SMTP : le provider reste nodemailer. Attention aussi au nom exact des variables : le code lit EMAIL_PASS, pas EMAIL_PASSWORD.

535 Authentication failed dans les logs — les identifiants SMTP sont refusés :

docker compose logs postiz | grep -iE "EAUTH|535|Email to .* failed"

Avant de soupçonner le mot de passe, vérifier la région Mailgun : un domaine créé en région EU ne s'authentifie pas sur le point d'entrée US, et l'erreur est un 535 indistinguable d'un mauvais mot de passe. smtp.mailgun.org = US, smtp.eu.mailgun.org = EU. Pour trancher sans deviner, tester les quatre combinaisons depuis le conteneur (le mot de passe n'est jamais affiché) :

docker compose exec -T postiz node -e '
const nm=require("/app/node_modules/nodemailer");
(async()=>{for(const h of ["smtp.mailgun.org","smtp.eu.mailgun.org"])
for(const c of [[465,true],[587,false]]){
 try{await nm.createTransport({host:h,port:c[0],secure:c[1],
  auth:{user:process.env.EMAIL_USER,pass:process.env.EMAIL_PASS},
  connectionTimeout:1e4}).verify();console.log("OK    "+h+":"+c[0]);}
 catch(e){console.log("ECHEC "+h+":"+c[0]+" -> "+(e.responseCode||e.code));}}})();'

Les envois passent par un workflow Temporal avec 3 tentatives : un échec laisse donc trois traces dans les logs, puis Email to <adresse> failed after 3 attempts.

Connecter un compte Mastodon — le provider Mastodon exige trois variables d'environnement, contrairement à Bluesky ou Nostr dont les identifiants se saisissent dans l'interface. Il faut déclarer une application sur ton instance (Préférences → Développement → Nouvelle application) avec :

Champ Valeur
URI de redirection https://<POSTIZ_DOMAIN>/integrations/social/mastodon
Permissions write:statuses, profile, write:media

puis renseigner MASTODON_URL (l'URL de l'instance, ex. https://piaille.fr), MASTODON_CLIENT_ID, MASTODON_CLIENT_SECRET et faire make reload. MASTODON_URL étant une variable globale, une instance Postiz ne peut se connecter qu'à une seule instance Mastodon. Le provider « M. Instance » (mastodon-custom), qui déclarerait l'application tout seul et permettrait plusieurs instances, existe dans le code mais est désactivé en v2.22.1 (integration.manager.ts : // new MastodonCustomProvider()).

Connecter Instagram et Facebook (apps Meta)

Rédigé le 2026-07-30 depuis les écrans réels, en connectant @lafilature_villeurbanne. Les consoles Meta changent souvent : si un libellé ne correspond plus, se fier à la logique (quel App ID, quels scopes, quelle URI) plutôt qu'au chemin exact.

1. Profil, Page, compte professionnel

Un profil est une personne, une Page est une entité administrée depuis un profil. La distinction décide de ce qui est automatisable :

  • Facebook interdit à tout outil tiers de publier sur un profil personnel (API fermée en 2018). Aucun réglage ne contourne ça — d'où le nom du provider « Facebook Page ».
  • Instagram exige un compte professionnel (Business ou Creator). Un compte personnel n'est pas publiable par API. La conversion se fait dans les réglages et est réversible.
  • LinkedIn autorise les deux, d'où deux providers distincts.

Distinguer une Page d'un profil — ouvrir https://www.facebook.com/<id-ou-nom> :

Indice Page Profil
URL nom personnalisé (/lafilaturevilleurbanne) redirige vers profile.php?id=…
Onglets Followers, avis Ami(e)s
Bouton Suivre / J'aime Ajouter

Un onglet « Ami(e)s » ⇒ profil ⇒ Postiz ne pourra jamais y publier. Attention : les deux peuvent coexister sous le même nom. Ne pas conclure qu'une Page n'existe pas parce que « Pages que vous gérez » est vide — cette liste ne montre que les Pages dont on est administrateur.

2. Les cinq providers et leurs prérequis

Provider (interface) Identifiant Publie sur Prérequis
Facebook Page facebook une Page Facebook Page administrée + app Meta
Instagram (Facebook Business) instagram compte IG pro rattaché à une Page Page administrée + IG pro lié
Instagram (Standalone) instagram-standalone compte IG pro, sans Page app Meta seule
LinkedIn linkedin profil personnel voir §8
LinkedIn Page linkedin-page Page entreprise voir §8

facebook, instagram et linkedin-page ont isBetweenSteps = true : après l'autorisation, Postiz demande quelle page connecter. C'est normal.

Câblage des variables (vérifié dans le code) :

Provider Variables lues
facebook FACEBOOK_APP_ID / FACEBOOK_APP_SECRET
instagram (Business) les mêmes FACEBOOK_APP_*
instagram-standalone INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET

Conséquences utiles :

  • Facebook Page et Instagram Business se configurent avec un seul couple d'identifiants.
  • Standalone a ses propres variables : les trois providers peuvent cohabiter.
  • Une app Meta peut servir plusieurs instances Postiz et plusieurs organisations. Une app n'est liée ni à une Page ni à une personne morale : c'est un client OAuth. Il suffit de déclarer plusieurs URI de redirection. C'est le modèle des outils de publication ; l'app appartient à l'opérateur et publie pour ses membres. Un seul dossier de vérification d'entreprise au lieu d'un par structure.

Quelle variante Instagram choisir — Standalone si on n'administre pas de Page, ou si on veut que le canal reste indépendant des droits Facebook. Business seulement pour le suivi des hashtags et les insights étendus (Meta l'écrit sur l'écran de configuration). Pour publier et programmer, Standalone suffit.

Ne pas connecter les deux variantes pour un même compte Instagram : on obtiendrait deux canaux vers la même cible, avec le risque de publier deux fois. Pour changer de variante, remplacer le canal.

3. Créer l'application Meta

Console : https://developers.facebook.com/apps/. Compte développeur requis (acceptation des conditions Meta au premier usage).

L'ancien formulaire en 5 étapes n'est plus accessible : une modale « Il existe une nouvelle façon de créer des applications avec Meta » bloque la page et ne se ferme pas. Passer par son bouton « Créer une application ».

Parcours : Détails (nom + e-mail) → Cas d'utilisation → Entreprise → Conditions requises → Vue d'ensemble.

  • Cas d'utilisation : cocher « Gérer les messages et les contenus sur Instagram ». Ajouter « Tout gérer sur votre Page » pour couvrir Facebook Page — les deux sont combinables. Meta grise les cas incompatibles et affiche un avertissement le cas échéant. Ne pas prendre un cas centré Facebook Login : il donne les mauvaises autorisations.
  • Entreprise : « Je ne veux pas associer de portefeuille business pour le moment » suffit en développement. Un portefeuille vérifié sera exigé pour publier l'app.
  • Conditions requises : « Aucune exigence identifiée » en mode développement.
  • Le bouton final vaut acceptation des conditions générales Meta.

Le nom de l'app s'affiche dans l'écran d'autorisation vu par le compte qui autorise. Modifiable ensuite sans changer l'App ID.

Une app en mode développement fonctionne pleinement sur les comptes et Pages administrés par les personnes déclarées dans ses rôles. C'est suffisant pour publier réellement. L'App Review et la vérification d'entreprise ne servent qu'à sortir du mode développement (donc à publier sur des Pages de tiers).

4. ⚠️ Deux App ID différents — ne pas les confondre

Le cas d'utilisation Instagram expose son propre identifiant, distinct de celui de l'application :

Ce qu'on lit dans la console Où ça va
App ID de l'app (visible dans l'URL du tableau de bord) FACEBOOK_APP_ID
ID d'app Instagram (écran « Configuration de l'API avec la connexion Instagram ») INSTAGRAM_APP_ID

Les confondre produit une erreur d'autorisation peu explicite. Contrôle : l'« URL d'intégration » affichée par Meta contient le bon client_id.

5. ⚠️ Postiz exige TOUS les scopes, Meta n'en ajoute qu'une partie

checkScopes() (social.abstract.ts) lève NotEnoughScopes si un seul scope manque. Or le bouton « Add all required permissions » n'ajoute pas les mêmes autorisations que celles demandées par Postiz.

Pour instagram-standalone :

Scope exigé par Postiz Ajouté automatiquement
instagram_business_basic oui
instagram_business_content_publish non
instagram_business_manage_comments non
instagram_business_manage_insights non

Piège dans le piège : Meta active instagram_manage_comments, sans business, qui est une autorisation différente. Le message « toutes les autorisations requises ont été ajoutées » est donc trompeur.

Les ajouter : cas d'utilisation → onglet Autorisations et fonctionnalités → « + Ajouter » sur chaque ligne. L'état passe à « Prête pour le test ».

Pour facebook et instagram (Business), la liste attendue est respectivement pages_show_list, pages_manage_posts, pages_manage_engagement, pages_read_engagement, business_management, read_insights — et instagram_basic, instagram_content_publish, instagram_manage_comments, instagram_manage_insights, pages_show_list, pages_read_engagement, business_management. Même vigilance.

Certains scopes sont partagés entre deux cas d'utilisation (mention « Trouvé dans 2 cas d'utilisation »). Cliquer « Ajouter » ouvre alors une modale de confirmation indiquant que l'autorisation sera aussi ajoutée à l'autre cas. Il faut la valider, sinon rien n'est enregistré — et l'ajout paraît silencieusement échouer.

⚠️⚠️ Meta injecte les permissions du cas d'utilisation, pas seulement celles demandées

Symptôme, au moment de connecter le canal Facebook Page :

Ce contenu n’est pas disponible pour le moment
Invalid Scopes: pages_read_user_content. This message is only shown to developers.
Users of your app will ignore these permissions if present.

Le message est doublement trompeur :

  • il annonce que les utilisateurs « ignoreront » ces permissions, alors que le dialogue OAuth est entièrement bloqué ;
  • il désigne un scope que Postiz ne demande jamaispages_read_user_content n'apparaît ni dans facebook.provider.ts, ni dans le bundle de l'image (vérifié par grep dans le conteneur, et en reproduisant l'URL d'autorisation à la main sans ce scope : l'erreur persiste).

Cause : Meta valide l'ensemble des permissions rattachées au cas d'utilisation de l'application, pas seulement celles présentes dans le paramètre scope=. Une permission du cas d'utilisation qui n'a pas été « ajoutée » est considérée comme invalide et fait échouer le dialogue.

Correctif : activer toutes les autorisations listées par le cas d'utilisation, y compris celles dont Postiz n'a pas besoin (pages_read_user_content, et le cas échéant pages_manage_metadata). Ajouter uniquement les scopes du provider ne suffit pas.

Pour diagnostiquer sans passer par l'interface Postiz, ouvrir l'URL d'autorisation à la main — un dialogue « Continuer en tant que … » signifie que c'est réglé :

https://www.facebook.com/v20.0/dialog/oauth?client_id=<FACEBOOK_APP_ID>
  &redirect_uri=<urlencode(https://<POSTIZ_DOMAIN>/integrations/social/facebook)>
  &state=diag&scope=pages_show_list,business_management,pages_manage_posts,
         pages_manage_engagement,pages_read_engagement,read_insights

Ne pas autoriser depuis cette URL de test : le state ne correspond à aucune session Postiz, le retour échouerait. Relancer depuis Ajouter un canal.

6. URI de redirection

Forme : https://<POSTIZ_DOMAIN>/integrations/social/<identifiant-provider>, où l'identifiant est celui du tableau du §2 (facebook, instagram, instagram-standalone, linkedin, linkedin-page, mastodon).

Le code la construit depuis FRONTEND_URL, que ce compose définit à https://${POSTIZ_DOMAIN} : les deux doivent correspondre exactement.

Où la déclarer pour Standalone : cas d'utilisation Instagram → onglet « Configuration de l'API avec la connexion Instagram » → étape 4. Configurez la connexion professionnelle Instagram → « Configurer ». Un seul champ ; « Paramètres de connexion professionnelle » permet ensuite d'en gérer plusieurs et de fournir les URL d'annulation et de suppression de données exigées à l'examen.

Une app servant plusieurs instances déclare autant d'URI que de domaines.

7. Le rôle testeur — indispensable en mode développement

Sans lui, l'autorisation échoue même si tout le reste est correct.

  1. Console → Rôles dans l'application → « Ajouter des personnes » → section « Rôles supplémentaires pour cette application » → Testeur(se) Instagram → saisir le nom de profil Instagram → « Ajouter ». Le nom devient un jeton validé : cliquer « Ajouter » deux fois (le premier clic valide la saisie, le second soumet). Statut : « En attente ».
  2. Côté Instagram, connecté avec le compte concerné : Paramètres → Applications et sites Web → onglet Invitations à tester → « Accepter ». Cette acceptation vaut acceptation des conditions Meta et des politiques développeur. Réversible depuis la même page.

8. Côté serveur

Vérifier d'abord que les variables existent dans le .env : un .env ancien peut n'avoir que FACEBOOK_APP_*, auquel cas ajouter les lignes Instagram. Le compose les passe déjà (${INSTAGRAM_APP_ID:-}), rien à y modifier.

cd ~/postiz
grep -q '^INSTAGRAM_APP_ID=' .env || printf '\nINSTAGRAM_APP_ID=<id-app-instagram>\n' >> .env

Le secret ne doit jamais s'afficher — ni à l'écran, ni dans l'historique, ni dans une capture de terminal. read -rs n'affiche rien et la valeur ne passe pas par la ligne de commande :

cd ~/postiz && read -rsp 'Cle secrete : ' S \
  && printf 'INSTAGRAM_APP_SECRET=%s\n' "$S" >> .env && unset S && echo && echo AJOUTE

Vérifier sans révéler (un App Secret Meta fait 32 caractères) :

awk -F= '/^INSTAGRAM_APP_SECRET=/{print length($2)}' .env

Puis make reload — jamais make up seul, voir Démarrage de l'orchestrator.

Contrôles après reload :

docker compose ps                       # postiz doit être (healthy)
docker compose exec -T postiz sh -lc 'env | grep -E "^(INSTAGRAM|FACEBOOK)_APP_ID"'
docker compose logs postiz | grep -iE "taskQueue.*(instagram|facebook)"
  • healthy prouve que l'orchestrator tourne : le healthcheck sonde /health/status:3002. À l'inverse, pm2 … status: online ne prouve rien — lors du deadlock le process était online et le port fermé.
  • Le worker doit apparaître en state: 'RUNNING' pour la file concernée. La file d'un post est providerIdentifier.split('-')[0] : instagram-standalone → file instagram. Si cette file est dans EXCLUDE_QUEUE, l'interface accepte les posts et rien ne se publie jamais.
  • Ne pas tester les ports avec /dev/tcp : absent du sh de l'image, faux négatif garanti.

9. Connecter le canal et vérifier

Interface Postiz → Ajouter un canal → le provider voulu → écran d'autorisation.

⚠️ L'écran de consentement Instagram présente chaque autorisation avec un interrupteur, tous activés par défaut : « Voir le profil » (requis), « Accéder aux commentaires », « Accéder au contenu et le publier », « Accéder aux statistiques ». En désactiver un seul fait échouer la connexion (NotEnoughScopes). Ne toucher à aucun interrupteur.

Retour attendu : ?added=<provider>&msg=Channel%20Updated. Vérifier que le canal est réellement exploitable, et pas seulement affiché :

docker compose exec -T postiz-postgres psql -U postiz -d postiz -c \
  'SELECT name, "providerIdentifier", disabled, "refreshNeeded", "inBetweenSteps", profile
   FROM "Integration";'

disabled et refreshNeeded à f (token valide, pas de réautorisation attendue), inBetweenSteps à f (aucune étape de sélection en suspens), profile = le bon compte.

10. Ce qui reste hors d'atteinte

Facebook Page — l'app est prête (cas d'utilisation « Tout gérer sur votre Page », variables FACEBOOK_APP_*), mais il faut être administrateur de la Page. Un administrateur actuel doit ajouter le compte : Page → Paramètres → Accès à la Page → Ajouter une personne, en accès Facebook complet — Postiz demande pages_manage_posts et business_management, et un accès partiel expose à un refus de scope difficile à diagnostiquer. Choisir un compte déjà déclaré dans les rôles de l'app, sinon le mode développement le refusera. Procédure non encore éprouvée de bout en bout dans ce dépôt.

LinkedIn — les deux providers demandent la même liste de scopes, incluant rw_organization_admin, w_organization_social et r_organization_social, qui relèvent de la Community Management API (pas en libre-service). Comme checkScopes() exige tout, même publier sur un profil personnel suppose d'obtenir l'accès organisation. Reporté.

Publier sur Instagram n'envoie rien sur Facebook : Postiz publie canal par canal, et le token Standalone est obtenu avec enable_fb_login=0, donc sans aucune permission Facebook. Le partage automatique Instagram → Page existe côté Meta, mais il est indépendant de Postiz et ne concerne pas les publications créées par API.

Plusieurs instances sur le même serveur

C'est prévu et éprouvé : rien n'est nommé en dur. Chaque instance est un clone du dépôt dans son propre dossier, avec son .env. Tout se dérive de COMPOSE_PROJECT_NAME — conteneurs, réseaux, volumes et routers Traefik.

Quatre variables doivent différer d'une instance à l'autre :

Variable Pourquoi
COMPOSE_PROJECT_NAME Unique sur l'hôte : nomme conteneurs, réseaux, volumes et routers Traefik
POSTIZ_DOMAIN Domaine distinct (+ enregistrement DNS A)
JWT_SECRET, POSTGRES_PASSWORD Secrets propres à chaque instance, jamais recopiés
TEMPORAL_UI_PORT Seul port publié (profil debug) : deux instances collisionneraient

Chaque instance a aussi son propre dépôt borgwarehouse et sa clé SSH — make init, lancé dans le dossier de l'instance, s'en occupe et pose sa propre ligne de cron (le cron matche le chemin absolu du script, donc les lignes ne s'écrasent pas).

Côté ressources, compter ~1,45 Gio par instance avec EXCLUDE_QUEUE bien réglé (voir la section empreinte mémoire).

⚠️ Piège : les variables exportées écrasent le .env

Docker Compose donne la priorité aux variables d'environnement du shell sur le fichier .env. Si tu as fait un set -a ; . .env ; set +a dans ton shell pour déboguer une instance — un réflexe courant pour utiliser borg à la main — puis que tu passes dans le dossier d'une autre instance, docker compose utilisera silencieusement le domaine, le nom de projet et les secrets de la première. Rencontré en préprod : le compose de la seconde instance se résolvait avec le domaine de la première.

Vérifier systématiquement avant de démarrer une instance :

docker compose config | grep -E "^name:|routers.*rule"

Et pour nettoyer un shell pollué :

unset $(env | grep -oE "^(POSTIZ_|COMPOSE_|BORG_|EMAIL_|EXCLUDE_|MASTODON_|JWT_|POSTGRES_|TEMPORAL_)[A-Z_]*" | tr "\n" " ")

C'est aussi une raison de plus de ne jamais sourcer le .env entier : les scripts de sauvegarde de ce dépôt n'extraient que les clés dont ils ont besoin (valeur_env()).

Ce qui n'a pas été activé (hors périmètre)

  • Cloudflare R2 : stockage local (bind mount) suffit pour l'instant.
  • OAuth générique / Keycloak : reporté, voir plus haut.

About

Self hosted postiz

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages