Published: Dec 10, 2024 by Isaac Johnson
Recently I found myself wanting to whip up a quick interactive whiteboard without having to resort to sharing a google drawing in a Teams meeting or using Miro with it’s nauseating zoom in and outs.
In this post we will explore three options: Lorien, Whitebophir and Spacedeck. Lorien is a simple local fat client for making quick drawings or whiteboarding locally. Whitebophir and Spacedeck are very similar. Both are containerized, easy to host in Docker or Kubernetes, and could solve my need for a shareable meeting whiteboard.
Lorien
Let’s begin by looking at the Github page for Lorien.
I’ll start by downloading it to WSL
builder@LuiGi:~$ mkdir lorien && cd lorien && wget https://github.com/mbrlabs/Lorien/releases/download/v0.6.0/Lorien_v0.6.0_Linux.tar.xz
--2024-11-27 19:07:00-- https://github.com/mbrlabs/Lorien/releases/download/v0.6.0/Lorien_v0.6.0_Linux.tar.xz
Resolving github.com (github.com)... 140.82.114.4, 2607:7700:0:2d:0:2:8c52:7204
Connecting to github.com (github.com)|140.82.114.4|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://objects.githubusercontent.com/github-production-release-asset-2e65be/364580533/9b3a6e4f-a688-4543-af2d-0c708cb0fd7f?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=releaseassetproduction%2F20241128%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20241128T010700Z&X-Amz-Expires=300&X-Amz-Signature=e33a7bbdf15860742cead2a3452391c7f9b029b593b9f9c0a6f210161c7f886b&X-Amz-SignedHeaders=host&response-content-disposition=attachment%3B%20filename%3DLorien_v0.6.0_Linux.tar.xz&response-content-type=application%2Foctet-stream [following]
--2024-11-27 19:07:00-- https://objects.githubusercontent.com/github-production-release-asset-2e65be/364580533/9b3a6e4f-a688-4543-af2d-0c708cb0fd7f?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=releaseassetproduction%2F20241128%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20241128T010700Z&X-Amz-Expires=300&X-Amz-Signature=e33a7bbdf15860742cead2a3452391c7f9b029b593b9f9c0a6f210161c7f886b&X-Amz-SignedHeaders=host&response-content-disposition=attachment%3B%20filename%3DLorien_v0.6.0_Linux.tar.xz&response-content-type=application%2Foctet-stream
Resolving objects.githubusercontent.com (objects.githubusercontent.com)... 185.199.111.133, 185.199.108.133, 185.199.109.133, ...
Connecting to objects.githubusercontent.com (objects.githubusercontent.com)|185.199.111.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 30753392 (29M) [application/octet-stream]
Saving to: ‘Lorien_v0.6.0_Linux.tar.xz’
Lorien_v0.6.0_Linux.tar.xz 100%[=================================================>] 29.33M 7.45MB/s in 4.8s
2024-11-27 19:07:05 (6.07 MB/s) - ‘Lorien_v0.6.0_Linux.tar.xz’ saved [30753392/30753392]
builder@LuiGi:~/lorien$ tar -xf Lorien_v0.6.0_Linux.tar.xz
builder@LuiGi:~/lorien$ ./Lorien_v0.6.0_Linux/Lorien.x86_64 Lorien_v0.6.0_Linux
Lorien_v0.6.0_Linux/ Lorien_v0.6.0_Linux.tar.xz
I tried to to launch it - and it did come up for a moment
But then quickly segfaulted
builder@LuiGi:~/lorien$ ./Lorien_v0.6.0_Linux/Lorien.x86_64
Godot Engine v3.5.3.stable.official.6c814135b - https://godotengine.org
MESA: error: ZINK: failed to choose pdev
glx: failed to create drisw screen
MESA: error: ZINK: failed to choose pdev
glx: failed to create drisw screen
MESA: error: ZINK: failed to choose pdev
glx: failed to create drisw screen
OpenGL ES 3.0 Renderer: D3D12 (Intel(R) Iris(R) Xe Graphics)
Async. shader compilation: OFF
Segmentation fault (core dumped)
That said, on the releases page there is a windows binary too
This seemed to launch without issue
As you can see it works okay - granted i’m not as fast with a trackpad as mouse:
My big issue (and a simple feature to see them add) is that there is no text. If I could just quick label things without handwriting that would be great.
Microsoft Office Whiteboard
As part of Office365, there is a whiteboard.office.com which is a bit slow but does work and is online (so the slowness could come from my slow hotspot where I’m writing presently)
Unlike the first option, it does have text
Whitebophir
Another nice option I found that can run in Docker is Whitebophir
They have simple docker steps
mkdir wbo-boards # Create a directory that will contain your whiteboards
chown -R 1000:1000 wbo-boards # Make this directory accessible to WBO
docker run -it --publish 5001:80 --volume "$(pwd)/wbo-boards:/opt/app/server-data" lovasoa/wbo:latest # run wbo
I can convert that into a Decent kubernetes manifest
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wbo-boards-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: wbo-deployment
spec:
replicas: 1
selector:
matchLabels:
app: wbo
template:
metadata:
labels:
app: wbo
spec:
containers:
- name: wbo
image: lovasoa/wbo:latest
ports:
- containerPort: 80
volumeMounts:
- mountPath: /opt/app/server-data
name: wbo-boards
volumes:
- name: wbo-boards
persistentVolumeClaim:
claimName: wbo-boards-pvc
---
apiVersion: v1
kind: Service
metadata:
name: wbo-service
spec:
selector:
app: wbo
ports:
- protocol: TCP
port: 80
targetPort: 80
Then applied it
$ kubectl apply -f ./wbo-boards.yaml
persistentvolumeclaim/wbo-boards-pvc created
deployment.apps/wbo-deployment created
service/wbo-service created
Then port-forward to test
$ kubectl port-forward svc/wbo-service 5550:80
Forwarding from 127.0.0.1:5550 -> 80
Forwarding from [::1]:5550 -> 80
Which works
This was quite easy to whip up a board
Actually, this was really performant. I’m going to kick a URL up for it next.
I’ll make a Route53 JSON
$ cat r53-whiteboard.json
{
"Comment": "CREATE whiteboard fb.s A record ",
"Changes": [
{
"Action": "CREATE",
"ResourceRecordSet": {
"Name": "whiteboard.freshbrewed.science",
"Type": "A",
"TTL": 300,
"ResourceRecords": [
{
"Value": "75.73.224.240"
}
]
}
}
]
}
And have AWS R53 create the A record
$ aws route53 change-resource-record-sets --hosted-zone-id Z39E8QFU0F9PZP --change-batch file://r53-whiteboard.json
{
"ChangeInfo": {
"Id": "/change/C09040521537OOOFI2EHH",
"Status": "PENDING",
"SubmittedAt": "2024-11-28T01:43:27.345000+00:00",
"Comment": "CREATE whiteboard fb.s A record "
}
}
I’ll then create an Ingress YAML
$ cat ingress.whiteboard.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
ingress.kubernetes.io/ssl-redirect: "true"
kubernetes.io/ingress.class: nginx
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
name: whiteboard
spec:
rules:
- host: whiteboard.freshbrewed.science
http:
paths:
- backend:
service:
name: wbo-service
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- whiteboard.freshbrewed.science
secretName: whiteboard-tls
and apply it
$ kubectl apply -f ./ingress.whiteboard.yaml
ingress.networking.k8s.io/whiteboard created
I can then wait on the cert to be satisified
$ kubectl get cert whiteboard-tls
NAME READY SECRET AGE
whiteboard-tls False whiteboard-tls 37s
$ kubectl get cert whiteboard-tls
NAME READY SECRET AGE
whiteboard-tls False whiteboard-tls 83s
$ kubectl get cert whiteboard-tls
NAME READY SECRET AGE
whiteboard-tls True whiteboard-tls 113s
Let’s see it in use:
I can create a “private” board which just makes a long unique URL
Which I can just copy and paste the URL into another browser (or share with others to use)
Spacedeck
Another interesting option is spacedeck-open
It has a docker-compose YAML
version: "2.0"
services:
spacedeck:
build: .
container_name: spacedeck
ports:
- "9666:9666"
volumes:
- ./storage:/app/storage
- ./database:/app/database
Let’s clone
builder@LuiGi:~/Workspaces$ git clone https://github.com/spacedeck/spacedeck-open.git
Cloning into 'spacedeck-open'...
remote: Enumerating objects: 2074, done.
remote: Counting objects: 100% (501/501), done.
remote: Compressing objects: 100% (151/151), done.
remote: Total 2074 (delta 401), reused 370 (delta 350), pack-reused 1573 (from 1)
Receiving objects: 100% (2074/2074), 5.09 MiB | 3.86 MiB/s, done.
Resolving deltas: 100% (1212/1212), done.
I’ll just fire it up
builder@LuiGi:~/Workspaces$ cd spacedeck-open/
builder@LuiGi:~/Workspaces/spacedeck-open$ ls
CHANGELOG.md LICENSE database helpers middlewares package.json spacedeck.js yarn.lock
Dockerfile README.md docker-compose.yml integrations models public styles
Gulpfile.js config docs locales package-lock.json routes views
builder@LuiGi:~/Workspaces/spacedeck-open$ docker compose up
[+] Building 38.8s (1/2) docker:default
=> [spacedeck internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 630B 0.0s
=> [spacedeck internal] load metadata for docker.io/library/node:18-alpine
Before we try as YAML, we’ll need to push that image somewhere as the project does not have a published image.
builder@builder-T100:~/spacedeck-open$ docker build -t spacedeck:0.1 .
[+] Building 32.0s (13/13) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 630B 0.0s
=> [internal] load metadata for docker.io/library/node:18-alpine 0.9s
=> [auth] library/node:pull token for registry-1.docker.io 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 157B 0.0s
=> [1/7] FROM docker.io/library/node:18-alpine@sha256:7e43a2d633d91e8655a6c0f45d2ed987aa4930f0792f6d9dd3bffc7496e44882 2.5s
=> => resolve docker.io/library/node:18-alpine@sha256:7e43a2d633d91e8655a6c0f45d2ed987aa4930f0792f6d9dd3bffc7496e44882 0.0s
=> => sha256:aa6f657bab0c137ad8a7930532dbd7c53338023e828890090a4070f47a2ed65d 40.09MB / 40.09MB 0.9s
=> => sha256:f477ea663f1c2a017b6f95e743b5ad72f8ff8bf861b0759a86ba1985f706308f 1.39MB / 1.39MB 0.3s
=> => sha256:7e43a2d633d91e8655a6c0f45d2ed987aa4930f0792f6d9dd3bffc7496e44882 7.67kB / 7.67kB 0.0s
=> => sha256:7000d2e73f938c4f62fdda6d398d7dffd50e6c129409ae2b1a36ccebf9289ffe 1.72kB / 1.72kB 0.0s
=> => sha256:870e987bd79332f29e9e21d7fa06ae028cfb507bb8bbca058f6eb60f7111cae9 6.20kB / 6.20kB 0.0s
=> => sha256:da9db072f522755cbeb85be2b3f84059b70571b229512f1571d9217b77e1087f 3.62MB / 3.62MB 0.2s
=> => extracting sha256:da9db072f522755cbeb85be2b3f84059b70571b229512f1571d9217b77e1087f 0.2s
=> => sha256:43c47a581c29baa57713ee0da7af754f3994227b64949cb5e9e4dbbc2108c6cd 449B / 449B 0.3s
=> => extracting sha256:aa6f657bab0c137ad8a7930532dbd7c53338023e828890090a4070f47a2ed65d 1.0s
=> => extracting sha256:f477ea663f1c2a017b6f95e743b5ad72f8ff8bf861b0759a86ba1985f706308f 0.1s
=> => extracting sha256:43c47a581c29baa57713ee0da7af754f3994227b64949cb5e9e4dbbc2108c6cd 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 7.72MB 0.1s
=> [2/7] WORKDIR /app 0.8s
=> [3/7] RUN apk add --no-cache chromium nss freetype freetype-dev harfbuzz ca-certificates 11.8s
=> [4/7] RUN apk add graphicsmagick ffmpeg ffmpeg-dev ghostscript 3.3s
=> [5/7] COPY package*.json ./ 0.1s
=> [6/7] RUN npm install 10.7s
=> [7/7] COPY . . 0.1s
=> exporting to image 1.6s
=> => exporting layers 1.6s
=> => writing image sha256:aa9624bccb8b24264c419873c89af776556796acd513cc86287c2fea7c093928 0.0s
=> => naming to docker.io/library/spacedeck:0.1
I’ll then push that to my private CR
builder@builder-T100:~/spacedeck-open$ docker tag spacedeck:0.1 harbor.freshbrewed.science/freshbrewedprivate/spacedeck:0.1
builder@builder-T100:~/spacedeck-open$ docker push harbor.freshbrewed.science/freshbrewedprivate/spacedeck:0.1
The push refers to repository [harbor.freshbrewed.science/freshbrewedprivate/spacedeck]
329089f994d9: Pushed
0663b90c631c: Pushed
605a5910c173: Pushed
732a9f505e99: Pushed
26b7725b6662: Pushed
c1226e3f2e76: Pushed
aae2f82b308d: Pushed
5003e477a3c5: Pushed
83ada9b02fd6: Pushed
75654b8eeebd: Pushed
0.1: digest: sha256:c33d126003c46e9f4915037fe66d0c61f56fdc477c5cead969efaea8f1f8d29f size: 2422
Let’s try as YAML
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: storage-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: database-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: spacedeck-deployment
spec:
replicas: 1
selector:
matchLabels:
app: spacedeck
template:
metadata:
labels:
app: spacedeck
spec:
imagePullSecrets:
- name: myharborreg
containers:
- name: spacedeck
image: harbor.freshbrewed.science/freshbrewedprivate/spacedeck:0.1
ports:
- containerPort: 9666
volumeMounts:
- mountPath: /app/storage
name: storage
- mountPath: /app/database
name: database
volumes:
- name: storage
persistentVolumeClaim:
claimName: storage-pvc
- name: database
persistentVolumeClaim:
claimName: database-pvc
---
apiVersion: v1
kind: Service
metadata:
name: spacedeck-service
spec:
selector:
app: spacedeck
ports:
- protocol: TCP
port: 9666
targetPort: 9666
Let’s fire that up
$ kubectl apply -f kubernetes.yaml
persistentvolumeclaim/storage-pvc created
persistentvolumeclaim/database-pvc created
deployment.apps/spacedeck-deployment created
service/spacedeck-service created
I’ll move on to exposing the ingress so I can test.
First, I’ll create an A Record in Cloud DNS
$ gcloud dns --project=myanthosproject2 record-sets create spacedeck.steeped.sp
ace --zone="steepedspace" --type="A" --ttl="300" --rrdatas="75.73.224.240"
NAME TYPE TTL DATA
spacedeck.steeped.space. A 300 75.73.224.240
Then fire up an ingress object
$ cat ./spacedeck.ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: gcpleprod2
ingress.kubernetes.io/proxy-body-size: "0"
ingress.kubernetes.io/ssl-redirect: "true"
kubernetes.io/ingress.class: nginx
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.org/client-max-body-size: "0"
nginx.org/proxy-connect-timeout: "3600"
nginx.org/proxy-read-timeout: "3600"
nginx.org/websocket-services: spacedeck-service
name: spacedeckgcpingress
spec:
rules:
- host: spacedeck.steeped.space
http:
paths:
- backend:
service:
name: spacedeck-service
port:
number: 9666
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- spacedeck.steeped.space
secretName: spacedeckgcp-tls
Once the cert is validated
$ kubectl get cert spacedeckgcp-tls
NAME READY SECRET AGE
spacedeckgcp-tls True spacedeckgcp-tls 2m52s
I can now test
But I cannot create an account without typing an “Invite code”. I tried a random “asdf”
It is actually set in a default JSON config
builder@LuiGi:~/Workspaces/spacedeck-open$ cat config/default.json | jq '.invite_code'
"top-sekrit"
I then used it for the signup
which worked
I can create a space to get started
This is surprisingly complete
Here we can see some collaboration between me using the website on my phone and then seeing the same thing locally in the laptop browser
or create and edit a new board
If I want to share it, I can generate an invite (assumes I set up email service) or get a URL
I set the board to be public and then put the URL in an incognito window
This could be handy for presentations or hnd generated architecture diagrams one might want to put into a webpage or wiki docs.
Just to make it easier for others to use, I tagged and pushed the container to dockerhub
builder@builder-T100:~/spacedeck-open$ docker tag spacedeck:0.1 idjohnson/spacedeck:0.1
builder@builder-T100:~/spacedeck-open$ docker push idjohnson/spacedeck:0.1
The push refers to repository [docker.io/idjohnson/spacedeck]
329089f994d9: Pushed
0663b90c631c: Pushed
605a5910c173: Pushed
732a9f505e99: Pushed
26b7725b6662: Pushed
c1226e3f2e76: Pushed
aae2f82b308d: Mounted from library/node
5003e477a3c5: Mounted from library/node
83ada9b02fd6: Mounted from library/node
75654b8eeebd: Mounted from library/node
0.1: digest: sha256:c33d126003c46e9f4915037fe66d0c61f56fdc477c5cead969efaea8f1f8d29f size: 2422
which you can find at https://hub.docker.com/r/idjohnson/spacedeck/tags
History
Spacedeck seemed far too complete to just be a hobby project. From the Github page, it was developed between 2011 and 2016 and was for a commercial SaaS that had been at http://spacedeck.com/ but was shuttered in 2018. The developers decided to open-source it for all in 2020.
I had to use the Wayback Machine to see it used to be just 5 Euro a month for a “Pro” account
However, it just sort of disappears in 2018 and nothing after. They left an index page in their Github that effectively says the same:
Spacedeck was a Berlin based SaaS business that provided web-based collaboration Spaces for individuals and teams. We closed our business in early 2018. The software lives on as an open source project at <a href="https://github.com/spacedeck">https://github.com/spacedeck</a>.
Summary
Today we looked at three very good options for self-hosted Open-Source whiteboards. We checked out Lorien, Whitebophir, and spacedeck-open.
Lorien was a fat binary that would run local. It works in a most basic sense and is quite fast - but without being able to write text, I cannot see how I would use this over MS Paint or whatever is local to my box.
The other two options are really quite close. Whitebophir is a simple container and a few of the options only show when double clicking. It has a straight anonymous options which is terrific for a fast whiteboard if in a meeting or consulting situation.
Spacedeck was clearly a polished commercial product first. I’m not as keen on having to create an account, but if one is trying to limit access, that could be a nice feature. I still have some issues with moving/editing items after creating them. It has to do with unclicking that pan option, but I believe with some practice this would be a good Miro alternative.
I’m leaving both open for others to try at whiteboard.freshbrewed.science (whitebophir) and spacedeck.freshbrewed.science (spacedeck-open). I have not changed the beta invite code, so just use “top-sekrit”.