Published: Jan 22, 2026 by Isaac Johnson
I tried out Mitchel’s Ghostty last February and wasn’t impressed then, but it was very new. I see more and more folks using it so I figured it might be time to revisit.
Additionally, I mentioned Alexandrie a few weeks back in a blog post. While one feature of it is being a Markdown editor, there looked to be a lot more going on and I wanted to really test it.
Ghostty
We can follow the instructions to install with a one line command
/bin/bash -c “$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)”
I couldn’t get it to work as a non-root user, but as root it installed
builder@LuiGi:~/Workspaces$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)"
Installing/Updating Ghostty...
Downloading ghostty_1.2.3-0.ppa1_amd64_25.10.deb...
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 15.8M 100 15.8M 0 0 2826k 0 0:00:05 0:00:05 --:--:-- 3399k
Installing ghostty_1.2.3-0.ppa1_amd64_25.10.deb...
[sudo: authenticate] Password:
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Note, selecting 'ghostty' instead of './ghostty_1.2.3-0.ppa1_amd64_25.10.deb'
Solving dependencies... Done
The following additional packages will be installed:
libgtk4-layer-shell0
The following NEW packages will be installed:
ghostty libgtk4-layer-shell0
0 upgraded, 2 newly installed, 0 to remove and 18 not upgraded.
Need to get 17.4 kB/16.6 MB of archives.
After this operation, 66.6 kB of additional disk space will be used.
Get:1 /home/builder/Workspaces/ghostty_1.2.3-0.ppa1_amd64_25.10.deb ghostty amd64 1.2.3-0~ppa1 [16.6 MB]
Get:2 http://us.archive.ubuntu.com/ubuntu questing/universe amd64 libgtk4-layer-shell0 amd64 1.0.4-2 [17.4 kB]
Fetched 17.4 kB in 0s (45.6 kB/s)
Selecting previously unselected package libgtk4-layer-shell0:amd64.
(Reading database ... 341368 files and directories currently installed.)
Preparing to unpack .../libgtk4-layer-shell0_1.0.4-2_amd64.deb ...
Unpacking libgtk4-layer-shell0:amd64 (1.0.4-2) ...
Selecting previously unselected package ghostty.
Preparing to unpack .../ghostty_1.2.3-0.ppa1_amd64_25.10.deb ...
Adding 'diversion of /usr/share/terminfo/g/ghostty to /usr/share/terminfo/g/ghostty.distrib by ghostty'
Unpacking ghostty (1.2.3-0~ppa1) ...
Setting up libgtk4-layer-shell0:amd64 (1.0.4-2) ...
Setting up ghostty (1.2.3-0~ppa1) ...
update-alternatives: using /usr/bin/ghostty to provide /usr/bin/x-terminal-emulator (x-terminal-emulator) in auto mode
Processing triggers for desktop-file-utils (0.28-1) ...
Processing triggers for hicolor-icon-theme (0.18-2) ...
Processing triggers for gnome-menus (3.36.0-3ubuntu2) ...
Processing triggers for libc-bin (2.42-0ubuntu3) ...
Processing triggers for man-db (2.13.1-1) ...
N: Download is performed unsandboxed as root as file '/home/builder/Workspaces/ghostty_1.2.3-0.ppa1_amd64_25.10.deb' couldn't be accessed by user '_apt'. - pkgAcquire::Run (13: Permission denied)
builder@LuiGi:~/Workspaces$ sudo su -
root@LuiGi:~# /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh)"
Installing/Updating Ghostty...
Downloading ghostty_1.2.3-0.ppa1_amd64_25.10.deb...
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 15.8M 100 15.8M 0 0 2645k 0 0:00:06 0:00:06 --:--:-- 2965k
Installing ghostty_1.2.3-0.ppa1_amd64_25.10.deb...
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Note, selecting 'ghostty' instead of './ghostty_1.2.3-0.ppa1_amd64_25.10.deb'
ghostty is already the newest version (1.2.3-0~ppa1).
Solving dependencies... Done
0 upgraded, 0 newly installed, 0 to remove and 18 not upgraded.
Launching with ghostty we see a nice font improvement over the standard Ubuntu terminal
<Old Man> So this last time I tried getting glasses they offered me “progressives”. No, these aren’t Bernie/AOC endorsed glasses, they are the hip term for ‘bifocals’. They suck. OMG, i want to vomit wearing them. the things move in different speeds at the bottom half giving you a “drunk goggles” view. So i refuse to wear them. So now I wear cheaters (what old people call reading glasses) when in bed and otherwise i just hold the phone/laptop out. You don’t really lose your near sight focus till your 40s so if you are younger, enjoy that sh** while you have it. I wish I would have known I needed to periodically use my close up vision or i would lose it when i was in my 20s and 30s. In any case, yes, old man keeps yammering.. in any case, I like this font way better for when I’m using the higher resolution 4k laptop screen… </Old Man>
Since I want to try it a bit now that my main driver is Linux, I pinned it to the dock
I can use ctrl + for increasing font size and ctrl - for lowering, which seems familiar
We can use the command ghostty +list-keybinds --default to see key bindings that are set by default (all of them can be changed as well)
Because Ghostty is a terminal, not a shell, it used BASH and showed me my history which was nice. My biggest gripe with MacOS and WSL nowadays is it keeps switching to zsh and I have to change it back
The super (windows key) + ctrl + bracket for moving to preview and next tabs are fine, but the default of ctrl + alt + arrow keys to move around split panes comes into conflict with Gnome in Ubuntu which uses ctrl + alt + arrow right/left for switching workspace windows.
Having the split windows means I don’t need tmux to accomplish having several panes going in the same window. For instance, I can have watch free -m and top going to watch memory and cpu then run a command in the third pane
While the close commands like close window tend to just end the whole Ghostty session, you can easily close terminal panes by just using exit to end that SSH session and the pane goes away
In the actual menu navigation of Ghostty, we can review, run and search for commands so those that like to use their mouse/trackpad can always go that way
Next, we’ll look again at Alexandrie, but I did want to point out that I used the split view in Ghostty to make it easier to update docker compose with images I had just built and pushed
Alexandrie
When I did that previous blog post on Alexandrie, I just tried it with localhost and docker.
Let’s do TLS and try a proper ingressed version.
I’ll first bring Alexandrie to an alternate docker host
builder@bosgamerz9:~$ git clone https://github.com/Smaug6739/Alexandrie.git
Cloning into 'Alexandrie'...
remote: Enumerating objects: 21479, done.
remote: Counting objects: 100% (557/557), done.
remote: Compressing objects: 100% (216/216), done.
remote: Total 21479 (delta 477), reused 351 (delta 341), pack-reused 20922 (from 4)
Receiving objects: 100% (21479/21479), 67.63 MiB | 30.23 MiB/s, done.
Resolving deltas: 100% (14411/14411), done.
builder@bosgamerz9:~$ cd Alexandrie/
builder@bosgamerz9:~/Alexandrie$ cp .env.example .env
builder@bosgamerz9:~/Alexandrie$
I’m going to need at least three A names to work with my Nginx ingress as I cannot really do port routing with my setup (with TLS that is). I could do HTTP with NodePorts and forwarding and take DNS out of the picture.
builder@LuiGi:~/Workspaces$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 174.53.161.33 -n alex
{
"ARecords": [
{
"ipv4Address": "174.53.161.33"
}
],
"TTL": 3600,
"etag": "6b6b33fa-a53d-4544-bfa5-9eef20a1778d",
"fqdn": "alex.tpk.pw.",
"id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/alex",
"name": "alex",
"provisioningState": "Succeeded",
"resourceGroup": "idjdnsrg",
"targetResource": {},
"trafficManagementProfile": {},
"type": "Microsoft.Network/dnszones/A"
}
builder@LuiGi:~/Workspaces$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 174.53.161.33 -n alex-cdn
{
"ARecords": [
{
"ipv4Address": "174.53.161.33"
}
],
"TTL": 3600,
"etag": "14e30bc3-eff6-4bdd-96a2-e4aee1702d8b",
"fqdn": "alex-cdn.tpk.pw.",
"id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/alex-cdn",
"name": "alex-cdn",
"provisioningState": "Succeeded",
"resourceGroup": "idjdnsrg",
"targetResource": {},
"trafficManagementProfile": {},
"type": "Microsoft.Network/dnszones/A"
}
builder@LuiGi:~/Workspaces$ az account set --subscription "Pay-As-You-Go" && az network dns record-set a add-record -g idjdnsrg -z tpk.pw -a 174.53.161.33 -n alex-api
{
"ARecords": [
{
"ipv4Address": "174.53.161.33"
}
],
"TTL": 3600,
"etag": "99b46428-6826-4192-968c-fda15e3ae8f7",
"fqdn": "alex-api.tpk.pw.",
"id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/idjdnsrg/providers/Microsoft.Network/dnszones/tpk.pw/A/alex-api",
"name": "alex-api",
"provisioningState": "Succeeded",
"resourceGroup": "idjdnsrg",
"targetResource": {},
"trafficManagementProfile": {},
"type": "Microsoft.Network/dnszones/A"
}
With these three DNS entries, I can then create a functional manifest to use them:
spoiler alert: for those following along, I’ll later realize i missed the proxy body size for CDN uploads and that the right port for CDN is 9005 not 9000
---
apiVersion: v1
kind: Endpoints
metadata:
name: alex-external-ip
subsets:
- addresses:
- ip: 192.168.1.142
ports:
- name: alexint
port: 8200
protocol: TCP
---
apiVersion: v1
kind: Endpoints
metadata:
name: alex-api-external-ip
subsets:
- addresses:
- ip: 192.168.1.142
ports:
- name: alex-apiint
port: 8201
protocol: TCP
---
apiVersion: v1
kind: Endpoints
metadata:
name: alex-cdn-external-ip
subsets:
- addresses:
- ip: 192.168.1.142
ports:
- name: alex-cdnint
port: 9000
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: alex-external-ip
spec:
clusterIP: None
clusterIPs:
- None
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
- IPv6
ipFamilyPolicy: RequireDualStack
ports:
- name: alex
port: 80
protocol: TCP
targetPort: 8200
sessionAffinity: None
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: alex-api-external-ip
spec:
clusterIP: None
clusterIPs:
- None
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
- IPv6
ipFamilyPolicy: RequireDualStack
ports:
- name: alex-api
port: 80
protocol: TCP
targetPort: 8201
sessionAffinity: None
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: alex-cdn-external-ip
spec:
clusterIP: None
clusterIPs:
- None
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
- IPv6
ipFamilyPolicy: RequireDualStack
ports:
- name: alex-cdn
port: 80
protocol: TCP
targetPort: 9000
sessionAffinity: None
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: azuredns-tpkpw
ingress.kubernetes.io/ssl-redirect: "true"
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.org/websocket-services: alex-external-ip
generation: 1
name: alexingress
spec:
ingressClassName: nginx
rules:
- host: alex.tpk.pw
http:
paths:
- backend:
service:
name: alex-external-ip
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- alex.tpk.pw
secretName: alex-tls
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: azuredns-tpkpw
ingress.kubernetes.io/ssl-redirect: "true"
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.org/websocket-services: alex-api-external-ip
generation: 1
name: alex-apiingress
spec:
ingressClassName: nginx
rules:
- host: alex-api.tpk.pw
http:
paths:
- backend:
service:
name: alex-api-external-ip
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- alex-api.tpk.pw
secretName: alex-api-tls
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: azuredns-tpkpw
ingress.kubernetes.io/ssl-redirect: "true"
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.org/websocket-services: alex-cdn-external-ip
generation: 1
name: alex-cdningress
spec:
ingressClassName: nginx
rules:
- host: alex-cdn.tpk.pw
http:
paths:
- backend:
service:
name: alex-cdn-external-ip
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- alex-cdn.tpk.pw
secretName: alex-cdn-tls
Then apply it
$ kubectl apply -f ./ingress-alex.yaml
endpoints/alex-external-ip created
endpoints/alex-api-external-ip created
endpoints/alex-cdn-external-ip created
service/alex-external-ip created
service/alex-api-external-ip created
service/alex-cdn-external-ip created
ingress.networking.k8s.io/alexingress created
ingress.networking.k8s.io/alex-apiingress created
ingress.networking.k8s.io/alex-cdningress created
In the .env file, I can now use those URLs
builder@bosgamerz9:~/Alexandrie$ cat .env | head -n21 | tail -n3
FRONTEND_URL=https://alex.tpk.pw
API_URL=https://alex-api.tpk.pw
CDN_URL=https://alex-cdn.tpk.pw
Then I can docker compose up to bring up the service
builder@bosgamerz9:~/Alexandrie$ docker compose up -d
[+] Running 45/45
✔ backend Pulled 14.3s
✔ fd4aa3667332 Pull complete 8.4s
✔ bfb59b82a9b6 Pull complete 8.6s
✔ 017886f7e176 Pull complete 10.7s
✔ 62de241dac5f Pull complete 10.7s
✔ 2780920e5dbf Pull complete 10.7s
✔ 7c12895b777b Pull complete 10.7s
✔ 3214acf345c0 Pull complete 10.7s
✔ 52630fc75a18 Pull complete 10.7s
✔ dd64bf2dd177 Pull complete 10.7s
✔ 4aa0ea1413d3 Pull complete 10.7s
✔ dcaa5a89b0cc Pull complete 10.8s
✔ 069d1e267530 Pull complete 10.8s
✔ 6191b4b5532e Pull complete 10.8s
✔ e92289148968 Pull complete 11.6s
✔ 7db81a621940 Pull complete 11.6s
✔ 8a3362ac00f3 Pull complete 11.6s
✔ mysql Pulled 13.7s
✔ 16506d4b4233 Pull complete 5.5s
✔ 3387bdf9bfcc Pull complete 5.5s
✔ 5a35458f48a1 Pull complete 5.5s
✔ 1bed572afc9f Pull complete 5.6s
✔ f3e7871685d1 Pull complete 5.6s
✔ 9e9c9ba70723 Pull complete 5.6s
✔ be113d11b355 Pull complete 6.1s
✔ 05bdba050124 Pull complete 6.1s
✔ 54a2bfb30cdf Pull complete 12.1s
✔ 05ed66656b21 Pull complete 12.1s
✔ 1c0ceff8a81b Pull complete 12.1s
✔ rustfs Pulled 16.3s
✔ 7d773df7a2be Pull complete 8.7s
✔ 39838f5db0cf Pull complete 9.0s
✔ 67b398cfa20b Pull complete 11.3s
✔ ffd60cccecd6 Pull complete 11.3s
✔ 4f4fb700ef54 Pull complete 11.4s
✔ 7746ba9768f2 Pull complete 11.4s
✔ frontend Pulled 11.2s
✔ 1074353eec0d Pull complete 5.2s
✔ d53378de7b14 Pull complete 8.2s
✔ 1e51518bad62 Pull complete 8.3s
✔ df42fde70614 Pull complete 8.3s
✔ 2fee64f62ee0 Pull complete 8.3s
✔ 4e30f5080197 Pull complete 8.7s
✔ a4ee522d4181 Pull complete 8.7s
✔ f806d52c7133 Pull complete 8.7s
[+] Running 8/8
✔ Network alexandrie_alexandrie-network Created 0.1s
✔ Volume "alexandrie_mysql_data" Created 0.0s
✔ Volume "alexandrie_rustfs_data" Created 0.0s
✔ Volume "alexandrie_rustfs_logs" Created 0.0s
✔ Container alexandrie-rustfs Healthy 5.8s
✔ Container alexandrie-mysql Healthy 10.8s
✔ Container alexandrie-backend Started 10.9s
✔ Container alexandrie-frontend Started
After the certs were clear, I could check the main site
I can then create an account
After account create, I was redirected to login. After login I get the main dashboard
I can then create a new workspace
I can then write a do a sample article, which by default is private. Flipping a toggle will make it public
Okay, it worked, but that URL looks pretty garbage
I can set some fields
But they didn’t really affect the page other than to render the markdown properly
In the editor there are some nice WYSIWYG controls for doing things like creating tables
Those just insert the proper markdown or HTML. I can still use Preview to see a rendered version
Interestingly, if I move from Public to Private, it doesn’t have a redirect page or a 404, rather shows a gray page (like a paywall might show)
One thing that threw me of a bit was the “Thumbnail”. A URL to an image didn’t work and it took searching the code a bit to figure out that it wants the contents of an SVG file. I use an Adobe icon as a small example and it looked like it worked:
If you put in SVG for the “Icon” or Emoji it shows in the nav on the left (here i used the AA logo)
And how it looks on the rendered page:
The print view cleans a lot of navigation up which works well for published documents
CDN
I wanted to do all this to see what the “CDN” would provide.
I tried to upload a small 1.5Mb video, but just saw a quickly disappearing error
“Failed to upload 005_Clowns_Defeated_WalkAway.mp4: [POST] “https://alex-api.tpk.pw/api/resources”:
The Console Log in the browser shows
https://alex-api.tpk.pw/api/resources": <no response> Failed to fetch
at async bl (DQlLeSm7.js:4:148284)
at async Te (DQlLeSm7.js:4:149130)
at async Proxy.post (CUjCRRl6.js:1:120)
at async ae (IzeUTYAo.js:3:225)Caused by: TypeError: Failed to fetch
at DQlLeSm7.js:4:69016
at i.o [as raw] (DQlLeSm7.js:4:67774)
at bl (DQlLeSm7.js:4:148297)
at Te (DQlLeSm7.js:4:149136)
at Proxy.post (CUjCRRl6.js:1:126)
at Proxy.x (DQlLeSm7.js:4:83514)
at ae (IzeUTYAo.js:3:233)
at Ms (DQlLeSm7.js:2:21315)
at dt (DQlLeSm7.js:2:21385)
at W0 (DQlLeSm7.js:4:15398)
Editing
Another quick test, besides video which didn’t really work was Mermaid
Both mermaid and mermaidjs tags failed to render
Trying to get videos to work, I ended up attempting to change the HTML escape blocks
builder@LuiGi:~/Workspaces/Alexandrie/backend$ git diff
diff --git a/backend/utils/escape-html.go b/backend/utils/escape-html.go
index c2b20329..c07675d5 100644
--- a/backend/utils/escape-html.go
+++ b/backend/utils/escape-html.go
@@ -37,7 +37,7 @@ func InitBluemonday() {
policy.AllowElements("input", "textarea", "button", "label", "select", "option", "fieldset", "legend")
// Multimedia
- policy.AllowElements("audio", "video")
+ policy.AllowElements("audio", "video", "source")
// Custom elements for frontend rendering
policy.AllowElements("tag")
@@ -79,6 +79,10 @@ func InitBluemonday() {
// Links
policy.AllowAttrs("href", "rel").OnElements("a")
+ // Multimedia attributes
+ policy.AllowAttrs("src", "poster", "controls", "loop", "autoplay", "muted", "preload", "playsinline").OnElements("audio", "video")
+ policy.AllowAttrs("src").OnElements("source")
+
// Form attributes
policy.AllowAttrs("checked").OnElements("input")
builder@LuiGi:~/Workspaces/Alexandrie/backend$
I then used the new image
then bounced the Alexandrie service
However, this just created an infinite loop of login resets.
The good news is that setting it back to the former image restored everything.
On video
The question becomes do we need video? I often question whether adding embedded video brings value or just unnecessary noise.
Would just a link to a video suffice for those that want a recording? Clearly printing a page with video wouldn’t work.
I wrote a an article on David in MSP using Gemini on a long prompt then tweaking with a few details and perhaps different language.
I find AI generated content kind of cheap, so I hesitate in saying “I wrote that”, i just shaped it with prompts, albeit a longer one:
the biblical story of David is about an unexpected hero who trusted in God when the rest of his community had given up hope and the leaders had no power to stop the enclosing army. It would seem that the unstoppable ICE activity going on (search Minneapolis ICE death for examples) is similar to the activity of the Philistines. I want to create a break down of the story from 1 Samuel and compare it to current events. The takeaway should focus on “Seeking our David” and putting our faith in God. Store the output in a file David.md
But regardless of that content, we can see how the chapter or section markers bring clarity and it is quite readable
Kanban
While it doesn’t affect the published content, we can view our articles in a Kanban view which could make it easy to think about backlog vs In Progress vs Done content
Backups
We can create backups from the settings section
Summary
We reviewed Ghostty which had been a while since we explored. I found it far more useful in native Linux than I had in WSL last Feb. I plan to keep using it as a daily driver.
We also revisited Alexandrie exposing it as three separate DNS entries. While I got a bit stuck on the DNS and RustFS setup, as well as file uploads, in the end we sorted it out.
The only outstanding issue was the inability to embed videos. I’m okay with some limitations - like a lack of a hero image or less UI tweaking. I do think people can get really hung up on fiddling with layout and UI. Those looking for a straight-forward document suite might find Alexandrie a really good option.































