❯ apps · Running

Éditeur visuel no-code avec Silex (GrapesJS) No-Code Visual Editor with Silex (GrapesJS)

Déploiement de Silex sur le cluster k3s — l'image upstream est en amd64 uniquement, j'ai donc dû builder ma propre image ARM64 via GitHub Actions, débusquer un défaut qui bind sur localhost à l'intérieur du conteneur, et basculer un package GHCR en public. Une petite histoire de trois pièges enchaînés. Deploying Silex on the k3s cluster — the upstream image is amd64-only, so I had to build my own ARM64 image with GitHub Actions, fight a default that binds to localhost inside the container, and toggle a GHCR package public. A short story of three traps in a row.

· 9 min de lecture 9 min read ·
k3s-clustersilexgrapesjsnocodek8sselfhost
🇫🇷 Lire cet article en français

Why a self-hosted visual editor

The initial need was simple: being able to import existing HTML/CSS/JS sites and rework them by drag & drop, without depending on a Webflow or a Wix. Three serious candidates in the open source ecosystem:

  • GrapesJS — a visual editing JS library, embeddable. But it is not a standalone server; you have to write the wrapper yourself.
  • Webstudio — a modern Webflow alternative, more greenfield-oriented. Limited import of existing HTML.
  • Silex — the official project built on top of GrapesJS, distributed as a ready-to-use npm package with a persistence backend, project management and native HTML import.

Silex ticks every box: self-hostable, GrapesJS-based, AGPL, and the @silexlabs/silex package exposes a CLI binary you can use directly.

Target architecture

silex.olig-homelab-2025.win
        │  (Cloudflare Access policy *.olig-homelab-2025.win)
        ▼
Cloudflare Tunnel (cloudflared, token mode)
        │
        ▼
Traefik Ingress  ──▶  Service silex:6805  ──▶  Deployment silex (1 replica, Recreate)
                                                      │
                                                      ▼
                                         PVC silex-data (10Gi, Longhorn)
                                         /silex-data/projects  (editable sources)
                                         /silex-data/hosting   (published sites)

Single replica + strategy: Recreate, because Silex writes its projects to the local filesystem — hence a ReadWriteOnce PVC and no rolling update. Same pattern as code-server.

The manifests — the usual ArgoCD pattern

Six files, copied from the existing pattern (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 pointing at silex/

ArgoCD’s root Application scans argocd/apps/ automatically, so a simple git push to actual-state triggers the deployment. No manual action on the cluster side — that is the comfort of a well-laid GitOps setup.

Trap #1: exec format error

First deployment, pod in CrashLoopBackOff. Logs:

exec /usr/local/bin/docker-entrypoint.sh: exec format error

Instant diagnosis: the image does not match the node’s architecture. Checking:

$ 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

A 100% ARM64 cluster (six Raspberry Pi). And on the Docker Hub side, a quick inspection of the silexlabs/silex manifests:

$ 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"]}
...

Every published tag is amd64-only. No ARM64 build on the official registry. First lesson learned: before referencing an upstream image, check its published architectures — a single docker manifest inspect call would have avoided the false start.

Trap #2: no local Docker buildx, GHA to the rescue

The obvious solution: build a multi-arch image myself. Except Docker Desktop was not running on the local machine, and the gh token did not have the write:packages scope needed to push to GHCR.

Rather than configuring everything locally, I followed the pattern already used for the blog: GitHub Actions builds and publishes. The GHA runner has buildx + QEMU ready to go, and the native GITHUB_TOKEN has write:packages with no extra configuration.

The Dockerfile is minimal — Silex is distributed as an npm package, so we lean on node:18-bullseye-slim, which is natively multi-arch:

FROM node:18-bullseye-slim
RUN npm install -g @silexlabs/[email protected] \
 && npm cache clean --force
WORKDIR /silex
EXPOSE 6805
CMD ["silex"]

The workflow is a carbon copy of the blog’s:

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 }}

An ARM64 build emulated through QEMU on an ubuntu-latest runner: 3 min 19 s for this image, perfectly acceptable.

Trap #3: GHCR makes new packages private

Image built, deployment updated to ghcr.io/yellowbeanie/silex:latest, ArgoCD syncs — and the pod falls back into an error:

Failed to pull image "ghcr.io/yellowbeanie/silex:latest":
failed to authorize: failed to fetch anonymous token:
401 Unauthorized

Unlike Docker Hub, GHCR marks every new package as private by default, even when you push it from a workflow in a public repo. Without a configured imagePullSecret, the kubelet cannot pull them.

Two options:

  1. Create an imagePullSecret in the namespace with a read-only PAT, sealed via Sealed Secrets — clean, but 15 minutes of plumbing.
  2. Toggle the package to Public in the GitHub user settings — one click.

This package contains exactly what anyone would get from npm install -g @silexlabs/[email protected] on an official Node base. No secrets, no proprietary code, no internal configuration. So public visibility, without hesitation. The precedent was already there: homelab-blog is public on GHCR too.

Toggle done in Settings → Danger Zone → Change package visibility → Public, then forcing a fresh pull:

kubectl delete pod -n silex -l app=silex

And there, in the 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 mounted and writable, two folders initialised, application up.

The environment variable that could trap you

Reading Silex’s .env.default during the investigation, one default deserves to be highlighted:

SILEX_HOST=localhost

Inside a container, binding to localhost = binding to loopback only. The Kubernetes Service routes to the pod IP, not to 127.0.0.1. Without an override, the pod would start, the HTTP readiness probe would pass (the kubelet probes 127.0.0.1, which works), and yet the Service would get connections refused.

Hence the override in the 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 let Silex trust the X-Forwarded-Proto header injected by Traefik and generate its redirects and cookies over HTTPS, while the internal pod-Service-Ingress connection stays on HTTP.

End-to-end validation

$ 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

Internal service reachable in 17 ms, pod healthy, PVC bound. What is left is adding the public route in Cloudflare Zero Trust (silex.olig-homelab-2025.win → http://silex.silex.svc.cluster.local:6805), which is a manual step on this homelab because the tunnel runs in token mode with dashboard-side configuration.

Lessons learned

Three traps, three habits to burn in for the next app:

  1. Check the published architectures before pulling. One second of docker manifest inspect silexlabs/silex:latest would have replaced an hour of debugging.
  2. GitHub Actions is often the fastest route to building a custom multi-arch image. No local Docker to debug, no PAT to manage, and GITHUB_TOKEN already covers GHCR.
  3. GHCR makes new packages private by default. Either you flip them public when the content allows it, or you plan for an imagePullSecret.

And a bonus trap worth keeping in mind for any containerised app: localhost inside a container is the 127.0.0.1 of the pod’s network namespace. Always force the bind to 0.0.0.0 when the app exposes a port to a Service.

Silex now runs behind silex.olig-homelab-2025.win, ready to import existing HTML sites by drag & drop. The GitOps pipeline keeps its promise: a new app, six YAML files and one workflow, not a single command typed on the cluster.

k3s-clustersilexgrapesjsnocodek8sselfhost