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.
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). 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:
- Create an
imagePullSecretin the namespace with a read-only PAT, sealed via Sealed Secrets — clean, but 15 minutes of plumbing. - Toggle the package to
Publicin 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:
- Check the published architectures before pulling. One second of
docker manifest inspect silexlabs/silex:latestwould have replaced an hour of debugging. - 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_TOKENalready covers GHCR. - 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.