Pourquoi un éditeur visuel auto-hébergé
Le besoin initial était simple : pouvoir importer des sites HTML/CSS/JS existants et les retravailler en drag & drop, sans dépendre d’un Webflow ou d’un Wix. Trois candidats sérieux dans l’écosystème open source :
- GrapesJS — bibliothèque JS d’édition visuelle, embarquable. Mais ce n’est pas un serveur autonome ; il faut écrire le wrapper soi-même.
- Webstudio — alternative moderne à Webflow, plus orientée greenfield. Import HTML existant limité.
- Silex — projet officiel construit sur GrapesJS, distribué sous forme de paquet npm prêt à l’emploi avec backend de persistance, gestion de projets et import HTML natif.
Silex coche toutes les cases : self-hostable, basé sur GrapesJS, AGPL, et le paquet @silexlabs/silex expose un binaire CLI directement utilisable.
Architecture cible
silex.olig-homelab-2025.win
│ (Cloudflare Access policy *.olig-homelab-2025.win)
▼
Cloudflare Tunnel (cloudflared, mode token)
│
▼
Traefik Ingress ──▶ Service silex:6805 ──▶ Deployment silex (1 replica, Recreate)
│
▼
PVC silex-data (10Gi, Longhorn)
/silex-data/projects (sources éditables)
/silex-data/hosting (sites publiés)
Single replica + strategy: Recreate parce que Silex écrit ses projets sur le système de fichiers local, donc PVC ReadWriteOnce et pas de rolling update. Même pattern que code-server.
image amd64-only · cluster 100 % ARM64
silexlabs/silex:latest n'existe qu'en amd64 ; tous les nœuds sont Raspberry Pi arm64.silexlabs/silex:latest only exists for amd64; all the nodes are Raspberry Pi arm64.
docker/build-push-action · linux/arm64
Le runner ubuntu-latest fournit buildx + QEMU ; GITHUB_TOKEN couvre write:packages sans PAT.The ubuntu-latest runner provides buildx + QEMU; GITHUB_TOKEN covers write:packages without a PAT.
ghcr.io/yellowbeanie/silex:latest · 401
GHCR met tous les nouveaux paquets en private. Sans imagePullSecret, le kubelet ne peut pas pull.GHCR sets all new packages to private. Without an imagePullSecret, the kubelet cannot pull.
toggle public ou imagePullSecret). Three cascading pitfalls: architecture (amd64 → ARM64),
CI/CD (GHA buildx + QEMU), registry (GHCR private by default →
toggle public or imagePullSecret). Les manifests — pattern ArgoCD habituel
Six fichiers, copiés sur le pattern existant (code-server, n8n, homepage) :
silex/
00-namespace.yaml
pvc-data.yaml # 10Gi, standard (Longhorn)
deployment.yaml
service.yaml # ClusterIP :6805
ingress.yaml # traefik, host silex.olig-homelab-2025.win
argocd/apps/silex.yaml # Application CRD pointant sur silex/
L’Application racine d’ArgoCD scanne argocd/apps/ automatiquement, donc un simple git push sur actual-state déclenche le déploiement. Pas d’action manuelle côté cluster — c’est le confort d’un GitOps bien posé.
Trap n°1 : exec format error
Premier déploiement, pod en CrashLoopBackOff. Logs :
exec /usr/local/bin/docker-entrypoint.sh: exec format error
Diagnostic instantané : l’image ne correspond pas à l’architecture du nœud. Vérification :
$ kubectl get nodes -o custom-columns=NAME:.metadata.name,ARCH:.status.nodeInfo.architecture
NAME ARCH
master-1 arm64
master-2 arm64
master-3 arm64
worker-1 arm64
worker-2 arm64
worker-3 arm64
Cluster 100 % ARM64 (six Raspberry Pi). Et côté Docker Hub, une rapide inspection des manifests silexlabs/silex :
$ curl -s "https://hub.docker.com/v2/repositories/silexlabs/silex/tags/?page_size=20" \
| jq '.results[] | {name, archs: [.images[].architecture]}'
{"name":"latest", "archs":["amd64"]}
{"name":"canary", "archs":["amd64"]}
{"name":"3.6.4", "archs":["amd64"]}
...
Tous les tags publiés sont amd64-only. Pas de build ARM64 sur le registry officiel. Première leçon retenue : avant de référencer une image upstream, vérifier ses architectures publiées — un seul appel docker manifest inspect aurait évité le faux départ.
Trap n°2 : pas de Docker buildx local, GHA à la rescousse
Solution évidente : builder soi-même une image multi-arch. Sauf que Docker Desktop n’était pas démarré sur la machine locale, et le token gh n’avait pas le scope write:packages pour pousser sur GHCR.
Plutôt que de tout configurer en local, j’ai suivi le pattern déjà utilisé pour le blog : GitHub Actions construit et publie. Le runner GHA a buildx + QEMU prêts à l’emploi, et le GITHUB_TOKEN natif a write:packages sans configuration supplémentaire.
Le Dockerfile est minimal — Silex est distribué comme paquet npm, donc on s’appuie sur node:18-bullseye-slim qui est nativement multi-arch :
FROM node:18-bullseye-slim
RUN npm install -g @silexlabs/[email protected] \
&& npm cache clean --force
WORKDIR /silex
EXPOSE 6805
CMD ["silex"]
Le workflow est un calque de celui du blog :
name: Build and push silex image
on:
workflow_dispatch:
push:
branches: [actual-state]
paths:
- 'silex/Dockerfile'
- '.github/workflows/silex.yml'
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./silex
platforms: linux/arm64
push: true
tags: |
ghcr.io/yellowbeanie/silex:latest
ghcr.io/yellowbeanie/silex:${{ github.sha }}
Build ARM64 émulé via QEMU sur runner ubuntu-latest : 3 min 19 s pour cette image, pleinement acceptable.
Trap n°3 : GHCR met les nouveaux paquets en privé
Image construite, deployment mis à jour vers ghcr.io/yellowbeanie/silex:latest, ArgoCD synchronise — et le pod retombe en erreur :
Failed to pull image "ghcr.io/yellowbeanie/silex:latest":
failed to authorize: failed to fetch anonymous token:
401 Unauthorized
Contrairement à Docker Hub, GHCR met tous les nouveaux paquets en private par défaut, même quand on les pousse depuis un workflow d’un repo public. Sans imagePullSecret configuré, le kubelet ne peut pas les pull.
Deux options :
- Créer un
imagePullSecretdans le namespace avec un PAT en lecture, scellé via Sealed Secrets — propre mais 15 minutes de plomberie. - Basculer le paquet en
Publicvia les settings de l’utilisateur GitHub — un clic.
Ce paquet contient exactement ce que n’importe qui obtiendrait avec npm install -g @silexlabs/[email protected] sur une base Node officielle. Aucun secret, aucun code propriétaire, aucune config interne. Donc visibilité publique, sans hésitation. Le précédent était déjà là : homelab-blog est aussi public sur GHCR.
Toggle effectué dans Settings → Danger Zone → Change package visibility → Public, puis force d’un nouveau pull :
kubectl delete pod -n silex -l app=silex
Et là, dans les logs :
███████ ██ ██ ███████ ██ ██ ██ ██ ██████
██ ██ ██ ██ ██ ██ ██ ██ ██
███████ ██ ██ █████ ███ ██ ██ █████
██ ██ ██ ██ ██ ██ ██ ██ ██
███████ ██ ███████ ███████ ██ ██ ████ ██████ .0.0
> [FsHosting] Created folders assets/ and css/ in /silex-data/hosting
> [FsStorage] Created default in /silex-data/projects
Silex is ready, open http://localhost:6805 in your browser
PVC monté et writable, deux dossiers initialisés, application up.
La variable d’environnement qui pourrait piéger
En lisant le .env.default de Silex pendant l’investigation, un défaut mérite d’être souligné :
SILEX_HOST=localhost
Dans un conteneur, bind sur localhost = bind sur loopback uniquement. Le Service Kubernetes route vers l’IP du pod, pas vers 127.0.0.1. Sans surcharge, le pod démarrerait, la readiness probe HTTP passerait (kubelet probe sur 127.0.0.1 qui marche), et pourtant le Service recevrait des connexions refusées.
D’où la surcharge dans le deployment :
env:
- name: SILEX_HOST
value: "0.0.0.0"
- name: SILEX_PORT
value: "6805"
- name: SILEX_PROTOCOL
value: "https"
- name: SILEX_URL
value: "https://silex.olig-homelab-2025.win"
- name: SILEX_FORCE_HTTPS_TRUST_XFP_HEADER
value: "true"
- name: SILEX_FS_ROOT
value: "/silex-data/projects"
- name: SILEX_FS_HOSTING_ROOT
value: "/silex-data/hosting"
SILEX_PROTOCOL=https + SILEX_FORCE_HTTPS_TRUST_XFP_HEADER=true permettent à Silex de faire confiance au header X-Forwarded-Proto injecté par Traefik et de générer ses redirections et cookies en HTTPS, alors que la connexion interne pod-Service-Ingress reste en HTTP.
Validation end-to-end
$ kubectl get pods -n silex
NAME READY STATUS RESTARTS AGE
silex-785dc667b-jskmw 1/1 Running 0 81s
$ kubectl get pvc -n silex
NAME STATUS CAPACITY ACCESS MODES STORAGECLASS
silex-data Bound 10Gi RWO standard
$ kubectl run -n silex curl-test --rm -it --restart=Never \
--image=curlimages/curl:latest -- \
curl -sS -o /dev/null -w 'http_code=%{http_code} time=%{time_total}s\n' \
http://silex.silex.svc.cluster.local:6805/
http_code=200 time=0.017409s
Service interne joignable en 17 ms, pod healthy, PVC bound. Reste à ajouter la route publique dans Cloudflare Zero Trust (silex.olig-homelab-2025.win → http://silex.silex.svc.cluster.local:6805), ce qui est manuel sur ce homelab parce que le tunnel tourne en mode token avec configuration côté dashboard.
Leçons retenues
Trois pièges, trois habitudes à graver pour la prochaine app :
- Vérifier les architectures publiées avant de pull. Une seconde de
docker manifest inspect silexlabs/silex:latestaurait remplacé une heure de debug. - GitHub Actions est souvent la voie la plus rapide pour builder une image custom multi-arch. Pas de Docker local à débugger, pas de PAT à gérer, le
GITHUB_TOKENcouvre déjà GHCR. - GHCR met les nouveaux paquets en privé par défaut. Soit on bascule public quand le contenu le permet, soit on prévoit un
imagePullSecret.
Et un piège bonus à garder en tête sur n’importe quelle app conteneurisée : localhost dans un container, c’est 127.0.0.1 du namespace réseau du pod. Toujours forcer le bind sur 0.0.0.0 quand l’app expose un port à un Service.
Silex tourne désormais derrière silex.olig-homelab-2025.win, prêt pour importer des sites HTML existants en drag & drop. Le pipeline GitOps tient sa promesse : une nouvelle app, six fichiers YAML et un workflow, pas une commande tapée sur le cluster.