Installer Ghost sur aaPanel avec Docker

Il y a quelques semaines je vous avait vaguement expliqué comment installer aaPanel pour gérer un linux. Avec aaPanel on peut installer en quelques clics un site web, un Wordpress ou un autre CMS. Le hic c'est que celui que je veux tester n'est pas dans la liste, mais heureusement aaPanel gère les container Docker et justement Ghost est disponible préinstallé dans ce mode.

Je prends ici l'exemple de Ghost, mais ce qui suit s'applique à n'importe quel container d'application web.

Docker

Dans aaPanel vous pouvez ajouter Docker Manager depuis l'AppStore, celui-ci s'avèrera suffisant pour installer quelques containers, mais trop léger pour faire la majorité des manipulations. L'avantage à l'installer c'est que ça ajoutera au serveur tout ce qui est nécessaire de base pour Docker/

L'alternative consiste à installer Portainer qui lui offre une interface complète pour Docker, dans un container (il existe des alternatives).

docker volume create portainer_data
docker run -d -p 8000:8000 -p 9000:9000 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce

Mais au final le plus simple est encore Docker Compose ou d'y aller directement en SSH, ou plus simplement avec le terminal intégré à aaPanel.

Il y a plusieurs choses importantes à paramétrer pour utiliser Ghost dans un Docker :

  • Le port que l'on restituera ou pas sur le host (la machine qui supporte Docker). Si on ne le configure pas Ghost sera uniquement accessible en local via l'IP du container (http://172.17.0.2:2368 par exemple) et non sur le réseau local et non sur le réseau local qui héberge le serveur. Sur une machine isolée, un VPS par exemple, ce n'est pas gênant car on servira de NGINX pour accéder au site en reverse proxy au passage gérer le certificat Lets'Encrypt. Par contre si le point d'entrée passe par un firewall sur une autre IP du LAN, il sera important d'exposer un port sur le host afin d'éviter le passage par un NGINX local, inutile dans ce cas.
  • Le second point consiste à paramétrer le répertoire qui contiendra les données (db, images, etc...) de notre site. Si on ne le fait pas Docker va créer un répertoire arbitraire pour y stocker les données du container tel que cela aura été défini par celui qui a créé le Docker (dans l'absolu ces données pourraient très bien rester dans le container, mais ce ne serait pas du tout une bonne idée). Sur un VPS on peut par exemple faire en sorte que les données soient dans un sous répertoire de l'utilisateur.
  • Enfin, et si l'on veut éviter d'avoir à le faire plus tard, il faut définir l'url du site que l'on va créer. C'est un point important car comme dans Wordpress, Ghost à besoin de connaitre l'url sur lequel il tourne pour la navigation. Si on ne le précise pas et que l'on redirige le site via un reverse proxy (par exemple : https://www.domain.tld > http://172.17.0.2:2368) cela semblera fonctionner mais certains liens seront incorrect puisqu'ils pointeront sur l'IP du container.

Mise en pratique

Création du container minimaliste :

docker run -d --name ghost ghost

Création du container avec la gestion des ports et de l'url :

docker run -d --name ghost -e url=https://www.domain.tld -p 3001:2368 ghost

Création du container avec la gestion des ports, de l'url en précisant du répertoire contenant les données, une option de redémarrage et une image particulière (pour l'exemple car idéalement on utilise celle par défaut qui est la dernière :latest) :

docker run --name ghost -p 3001:2368 -e url=https://www.domain.tld -v /home/lionel/ghost/content:/var/lib/ghost/content --restart=always -d ghost:1.21.1-alpine

En fait cette dernière possibilité, bien que proposée ne fonctionne pas, ou plus, au niveau de l'url. Je vais donc créer mon docker sans l'url et en profiter pour mettre mes données dans un répertoire un peu plus parlant. Pour ça je vais au préalable créer un volume.

docker volume create canaletto_data
docker run --name canaletto -p 3001:2368 -v canaletto_data:/var/lib/ghost/content --restart=always -d ghost

Tout ça c'est bien beau si on a qu'un seul container et que l'on ne veut pas exposer les ports. Le problème dans cette configuration c'est que mes containers vont s'attribuer par défaut des IP par ordre de démarrage. Par défaut le host docker à 172.17.0.1 et le premier container à être lancé prendra la suivante et ainsi de suite... Sauf que mon reverse proxy aura besoin de connaitre une IP fixe.  Il me faut donc trouver la solution pour figer leur IP. Simple il dit lui ! J'ai fini par trouver une méthode simple qui semble faire le job :

On crée un nouveau réseau Docker que je vais appeler my_bridge :

docker network create --subnet=172.18.0.0/16 --gateway=172.18.0.1 my_bridge
docker network ls  (ls pour voir les réseaux, rm pour en supprimer un)

Ensuite on crée le container en spécifiant le réseau à utiliser et l'IP à lui affecter et cela permet également d'isoler les container entre eux (entre temps j'ai bien sur crée une image de mon précédent container pour ne pas le perdre) :

docker run --name Ghost-2 --net my_bridge --ip 172.18.0.10 -p 3010:2368 -v canaletto_data:/var/lib/ghost/content --restart=always -d ghost:2

Ensuite je vais aller éditer le fichier de configuration Ghost qui se trouve dans le docker.

docker ps pour repéréer l'ID
docker container exec -it b23f1490ccfb /bin/bash

Et une fois dans le docker

apt-get update
apt-get install nano
nano config.production.json

Et là je vais modifier l'url qui se présente ainsi "url": "http://localhost:2368", en "url": "https://ghost.canaletto.fr",. Attention à bien sortir avec un exit si vous souhaitez y revenir... (ce point sur l'url n'est pas clair dans ma tête).

Une fois notre docker installé on a plusieurs choix possibles pour y accéder

  • En direct si on a exposé le port 80 ou sur le port 2368 qui est le port par défaut de Ghost dans le container, et à condition d'ouvrir le port dans aaPanel. C'est clairement plus que déconseillé car d'une part il n'y aura pas de SSL et d'autre part la sécurité minimale impose un reverse proxy et votre site exposé directement risque de se faire rapidement descendre. De plus ça limiterait à un site par IP.
  • Via le reverse proxy d'un autre firewall sur le LAN. Dans mon cas j'utilise pfsense avec HAProxy et Acme pour gérer les certificats. Je vais donc faire pointer HAProxy sur l'IP et le port exposée sur mon host Docker (https://www.domain.tld > http://192.168.1.10:3001 (IP LAN de mon serveur Docker qui est dans une VM))
  • Enfin si on est par exemple dans un VPS on va y accéder via un site créé avec NGINX, dans lequel on va gérer le certificat via Let's Encrypt et configurer la partie reverse proxy qui va pointer sur l'IP et le port du container. C'ets ce que l'on va voir dans le chapitre suivant.

NIGNX

On va ici faire les choses via aaPanel qui nous sert d'interface. Mais ceux connaissent peuvent bien sur éditer les fichiers de configuration idoines. NIGNX s'installe via l'App Store de aaPanel en un clic. Ensuite on va aller créer un site via le menu Website avec le nom de domaine que l'on souhaite utiliser, ici ghost.canaletto.fr sur le port par defaut qui est le 80. On associe pas de FTP (on pourra le faire plus tard et ainsi le faire pointer sur le répertoire des données Ghost, pas de Database car elle est gérée par Ghost, pas de PHP car c'est ici inutile. Pour le SSL il faudra de toutes façons renseigner plus tard la méthode (vérification par fichier ou par DNS via l'API CloudFlare si le DNS du domaine est géré par eux).

Ensuite on va aller ajouter un reverse proxy dans NIGNX et ainsi rediriger les requêtes vers l'IP et le port du container qui contiens Ghost en n'oubliant pas de préciser le nom du domaine utilisé. Attention, pour je ne sais quelle raison il faut configurer le certificat avant de mettre en place le reverse proxy. C'est probablement une limitation (autre nom pour un bug) liée à aaPanel.

A ce stade Ghost est normalement accessible et on peut commencer à le configurer via l'url https://www.domain.tld/ghost. La suite coule de source et est expliquée dans la documentation Ghost par ailleurs très complète.

Bonus

Le gros avantage de Ghost est que l'on peut lui injecter du code. Soit dans l'admin pour des fonctionnalités globales, soit dans une page ou un article, par exemple pour y ajouter directement un bouton Paypal si l'article présente un produit que vous vendez, ou encore un formulaire ou une vidéo externe.

Au niveau global on peut injecter du code dans deux parties, Site Header et Site Footer.

Zoom

Imaginons que je veuille agrandir les images en cliquant dessus. Je vais alors utiliser ce composant (il en existe d'autres) et ajouter deux scripts dans la partie "footer". Le premier le chargera via un CDN tandis que le second permettra son exécution.

<script async src="https://cdn.jsdelivr.net/gh/coreysnyder04/[email protected]a01/fluidbox-ghost-blog-plugin.min.js"></script>
<script>
    window.fluidboxGhostConfig = {
      theme: 'image-backdrop', // Options: light, dark, image-backdrop, hsla(262, 100%, 82%, 0.6)
      showCaption: true, // Sets whether to capture the caption and show it below the image when expanded
    }
</script>
Le code

Comme je publie régulièrement du code je vais ajouter Prism afin d'obtenir une présentation élégante des différents langages affichés. Je commence par le header et on termine par le footer. Au passage je vais également chercher une police chez Google et j'ajoute des modification de style pour la présentation du code (au début) mais également afin d'utiliser ma nouvelle police à la place de celle proposée par le thème par défaut. Et au passage dans le footer je vais également ajouter de quoi numéroter les lignes de mon code et ouvrir les liens dans une nouvelle fenêtre.

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/themes/prism-okaidia.min.css" integrity="sha256-Ykz0nNWK7w4QWJUYR7OraN4773aMB/11aMt1nZyrhuQ=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/plugins/line-numbers/prism-line-numbers.min.css">

<link href='https://fonts.googleapis.com/css?family=Encode+Sans:300&display=swap' rel='stylesheet'>

	<style type="text/css" media="screen">
        .post-full-content pre strong {
            color: white;
        }
        .post-full-content pre {
            line-height: 0.9;
        }
        .post-full-content pre code {
            white-space: pre-wrap;
            hyphens: auto;
            line-height: 1.6;
            font-size: 0.7em;
        }
        pre[class*=language-] {
        	margin: 1.75em 0;
    	}
        .post-template p {
 			text-align: justify;
		}
  
  		p { 
            font-family: 'Encode Sans', sans-serif; 
        }      
		li { 
            font-family: 'Encode Sans', sans-serif; 
        }      
        blockquote {
            font-family: 'Encode Sans', sans-serif; 
        }      

    </style>
<script>
    window.addEventListener('DOMContentLoaded', (event) => {
        document.querySelectorAll('pre[class*=language-]').forEach(function(node) {
            node.classList.add('line-numbers');
        });
        Prism.highlightAll();
    });
</script>
<script type='text/javascript'>
  $( document ).ready(function() {
  	$(".post-content a").attr("target","moredetail");
  });
</script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/prism.min.js" integrity="sha256-NFZVyNmS1YlmiklazBA+TALYJlJtZj/y/i/oADk6CVE=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-markup-templating.min.js" integrity="sha256-41PtHfb57czcvRtAYtUhYcSaLDZ3ahSDmVZarE0uWPo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-javascript.min.js" integrity="sha256-KxieZ8/m0L2wDwOE1+F76U3TMFw4wc55EzHvzTC6Ej8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-css.min.js" integrity="sha256-49Y45o2obU1Yv4zkYDpMDyAa+D9sgKNbNy4ZYGRl/ls=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-php.min.js" integrity="sha256-gJj4RKQeXyXlVFu2I8jQACQZsii/YzVMhcDT99lr45I=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-sql.min.js" integrity="sha256-zgHnuWPEbzVKrT72LUtMObJgbwkv0VESwRfz7jpdsq0=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-yaml.min.js" integrity="sha256-JoqiKM2GipZjbGjNyl62d6qjQY1F9QTLriWOe4N76wE=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-sass.min.js" integrity="sha256-3oigyyaPovKMS9Ktg4ahAD1R6fOSMGASuA03DT8IrvU=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-json.min.js" integrity="sha256-18m89UBQcWGjPHHo64UD+sQx4SpMxiRI1F0MbefKXWw=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-bash.min.js" integrity="sha256-0W9ddRPtgrjvZVUxGhU/ShLxFi3WGNV2T7A7bBTuDWo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-python.min.js" integrity="sha256-zXSwQE9cCZ8HHjjOoy6sDGyl5/3i2VFAxU8XxJWfhC0=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/components/prism-ruby.min.js" integrity="sha256-SGBXZakPP3Fv0P4U6jksuwZQU5FlC22ZAANstHSSp3k=" crossorigin="anonymous"></script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.16.0/plugins/line-numbers/prism-line-numbers.min.js"></script>
Edition

L'éditeur de Ghost fonctionne en Markdown qui est devenu un standard de fait. Mais il est également possible de créer ses articles avec un éditeur externe comme Zettlr! ou zNote (il en existe plein d'autres). Personnellement je préfère TinyMCE à Markdown, mais c'est surement parce que je suis vieux et que j'ai été lobotomisé par Word...

Traduction

Ghost est livré en anglais. Mais il est prévu pour être traduisible. Pour autant la chose n'est pas forcément simple et va demander un peu de patience. Cela se fait au niveau du thème, et donc chaque traduction est propre à chaque thème. C'est plutôt bien expliqué ici et on trouve des thèmes (payant) qui sont traduits. Dans l'absolu il est même possible de traduire le backoffice, de même qu'il sera possible de configurer Ghost en multi langage (ça c'est plus compliqué).

J'ai commencé à traduire le thème de base, Casper. Donc on commence par faire une copie du thème, on ouvre le .zip et on le renomme. Ensuite il va falloir ajouter une répertoire /locales dans le thème et y ajouter à minima un fichier fr.json (qui suit). Le plus fastidieux sera d'aller dans les templates ajouter {{t à gauche des tous les textes et de terminer par }} (donc Page suivante devient {{t page suivante }}). Fastidieux mais pas énorme non plus.

Explications à venir... (ajout des autres ficjiers)

{
    "Back": "Retour",
    "Newer Posts": "Articles plus récents",
    "Older Posts": "Articles plus anciens",
    "Page {page} of {pages}": "Page {page} sur {pages}",
    "Subscribe": "S’abonner",
    "Subscribe to {blogtitle}": "S’abonner à {blogtitle}",
    "Subscribed!": "Abonné !",
    "with the email address": "avec l’adresse e-mail",
    "Your email address": "Votre adresse e-mail",
    "You’ve successfully subscribed to": "Vous vous êtes abonné avec succès à",
    "A collection of posts": "Une catégorie d’articles",
    "A collection of 1 post": "Une catégorie avec un article",
    "A collection of % posts": "Une catégorie avec % articles",
    "Get the latest posts delivered right to your inbox": "Recevez les derniers articles directement dans votre boîte aux lettres.",
    "Go to the front page": "Aller sur la page d’accueil",
    "Latest Posts": "Derniers articles",
    "Message:": "Message :",
    "<a href='{url}'>More posts</a> by {name}": "<a href='{url}'>Plus d’articles</a> par {name}",
    "No posts": "Aucun article",
    " (Page %)": " (Page %)",
    "Read More": "En savoir plus",
    "Read <a href='{url}'>more posts</a> by this author": "Lire <a href='{url}'>plus d’articles</a> de cet auteur",
    "Ref:": "Réf. :",
    "See all % posts": "Voir les % articles",
    "Share this": "Partager",
    "Stay up to date! Get all the latest & greatest posts delivered straight to your inbox": "Restez à jour ! Recevez tous les derniers articles directement dans votre boîte aux lettres.",
    "This post was a collaboration between": "Cet article est une collaboration entre",
    "[email protected]": "[email protected]",
    "1 post": "Un article",
    "% posts": "% articles",
    "1 min read": "1 min de lecture",
    "% min read": "% min de lecture"
}
Les intégrations

Ghost supporte beaucoup d'intégrations de base et il est possible d'en ajouter simplement en modifiant le code. Par exemple si on souhaite ajouter des commentaires Disqus il suffit d'ajouter ce code dans le thème à l'endroit idoine :

<div id="disqus_thread"></div>
<script>
    var disqus_config = function () {
        this.page.url = "{{url absolute="true"}}";
        this.page.identifier = "ghost-{{comment_id}}"
    };
    (function() {
    var d = document, s = d.createElement('script');
    s.src = 'https://EXAMPLE.disqus.com/embed.js';
    s.setAttribute('data-timestamp', +new Date());
    (d.head || d.body).appendChild(s);
    })();
</script>

Mais si on préfère que les commentaires soient gérés par Telegram et être notifié par ce canal on configurera cette app et on collera le script au même endroit. Il existe d'autre solutions comme Cove ou mieux Commento qui lui devra être installé localement, dans un autre container Docker par exemple), mais qui présente l'avantage de l'autonomie et de ne pas partager les données des commentateurs.

Pour le reste il est bien sur possible de créer ses propres intégrations en codant un peu... La documentation et l'importante communauté via le forum seront des bonnes sources.

Epilogue

Voila, je cherchait à m'affranchir du mammouth (Wordpress que je déteste) et je crois que j'ai enfin trouvé avec Ghost la bonne solution alternative. En plus comme Ghost est multi auteurs on va peut être en faire un blog communautaire. L'exemple est ici https://ghost.canaletto.fr ! Pour l'instant j'utilise BlogEngine pour ce site et peut être qu'un jour je tenterait la migration, d'autant plus que certains l'ont fait et qu'il existe des convertisseurs.

Enjoy !

Sources

 

Calibre Web & Docker...

J'utilise Calibre pour gérer une bibliothèque libre d'ouvrages numériques. Pour la partager avec mes enfants j'avais jadis monté sur un Synology Calibre Web sous Docker, sans trop savoir de quoi il en retournait, mais bon ça fonctionnait depuis deux ans. J'ai donc décidé d'aller plus loin et de monter ça sur une VM Ubuntu

Installer la VM et Docker n'est pas un soucis et il existe plein de tutos. Installer le container Calibre Web n'est pas non plus un problème, par contre ce que j'ai mis des jours à essayer de faire fonctionner c'est relier ma bibliothèque restée sur le Nas. Naturellement j'ai essayé de monter un volume NFS, voire CIFS, si je parvenais bien à lire et écrire dans ce volume depuis le container Calibre-Web, je n'ai jamais réussit à ce que Calibre Web puisse ainsi fonctionner. Il se passe quelque chose que le développeur de cette image ne sait expliquer, et encore moins moi. Mais en informatique, quand on ne sait pas faire quelque chose, on essaie de trouver un contournement, et je me suis donc dit que si le volume NFS monté dans Docker n'était pas exploitable, ça pourrait peut être fonctionner avec un volume NFS monté directement sous Linux. Et banco !

Explications...

Alors on commence par mettre à jour et installer NFS

> sudo apt update
> sudo apt install nfs-common

Puis on crée les deux répertoires locaux que l'on va utiliser pour nos montages :

> sudo apt update
> sudo apt install nfs-common

Et on mappe nos ressources distantes :

> sudo mount 192.168.20.22:/volume2/Public/Books/calibre /nfs/books
> sudo mount 192.168.20.22:/volume2/Public/Books/calibre-web /nfs/books-config

On crée nos répertoires de travail qui seront utilisés par Calibre Web, ça va nous permettre de vérifier que ça fonctionne :

> sudo mkdir -p /nfs/books.config/config
> sudo mkdir -p /nfs/books.config/app
> sudo mkdir -p /nfs/books.config/kindlegen

Et ensuite on édite le fichier /etc/fstab afin de rendre la chose persistante :

. . .
192.168.20.22:/volume2/Public/Books/calibre /nfs/books   nfs auto,nofail,noatime,nolock,intr,tcp,actimeo=1800 0 0
192.168.20.22:/volume2/Public/Books/calibre-web /nfs/books-config    nfs auto,nofail,noatime,nolock,intr,tcp,actimeo=1800 0 0

Bon, je n'ai bien sur rien inventé et les détails sont par exemple ici.

Maintenant je vais pouvoir créer mon Docker calibre-web après avoir adapté la config à ma sauce, c-a-d que tous mes répertoires de travail seront sur mon Nas :

> docker create --name=calibre-web --restart=always \
-v /nfs/books:/books \
-v /nfs/books-config/config:/calibre-web/config \
-v /nfs/books-config/app:/calibre-web/app \
-v /nfs/books-config/kindlegen:/calibre-web/kindlegen \
-e USE_CONFIG_DIR=true \
-e SET_CONTAINER_TIMEZONE=true \
-e CONTAINER_TIMEZONE=Europe/Paris \
-e PGID=65539 -e PUID=1029 \
-p 8083:8083 \
technosoft2000/calibre-web

J'aurais pu faire ça avec Portainer par exemple, mais c'est finalement bien plus simple de le faire en CLI, surtout avec la masse d'essai que j'ai du faire... D'autant plus que ça ne fonctionne pas, Calibre Web ne fonctionne pas avec le répertoire de config distant. Je me suis donc résolu à le laisser en local, mais pas dans le container mais un répertoire local au cas ou je casserait le container...

> docker create --name=calibre-web --restart=always \
-v /nfs/books:/books \
-v /var/lib/docker/data/calibre-web:/calibre-web/config \
-e USE_CONFIG_DIR=true \
-e SET_CONTAINER_TIMEZONE=true \
-e CONTAINER_TIMEZONE=Europe/Paris \
-e PGID=65539 -e PUID=1029 \
-p 8083:8083 \
technosoft2000/calibre-web

Voilà ! Et comme je ne pense pas pouvoir récupérer mon ancienne configuration, il ne me reste plus qu'à reconfigurer et à créer mes utilisateurs.

Sources