I self-hosted EdenText on Ubuntu 24.04 — the VPS volume held nothing

I self-hosted EdenText on Ubuntu 24.04 — the VPS volume held nothing

I wanted a Word-like editor on my own hostname, not another hosted doc tab. EdenText came up as one nginx container. The library lives in that browser's localStorage, so a tar of /opt/edentext backs up compose, not the letters. Loopback 8080, pin v0.14.0, HTTPS, export .odt — that is the lab path.

 EdenText on Ubuntu and Docker

Caption: Browser to Caddy on 443, nginx serving the static app on loopback 8080. localStorage holds the documents. The VPS disk never sees an .odt. I almost published 8080 and tarred an empty project folder.

Why I wanted this on my server

I already have Stirling PDF for merge, OCR, and compress. That post was the PDF toolbox. What I do not have is a tab that opens a real .odt or .docx, lays out A4, and does not send the draft to somebody else's docs product while I fix a heading. Google Docs is the habit. LibreOffice on the laptop is the honest alternative. Neither is a hostname I control that I can hand a client for a one-page letter.

Self-Host Weekly put EdenText in the 6 Nov 2026 spotlight: a Word-like ribbon in the browser, page layout, styles, tables, review tools, optional AI assistant, Docker. AGPL from Steffen Becker. One container. No database. The weekly said it stores data locally in the browser, so save or export anything important. That sentence is the whole install, and I still almost treated it like Nextcloud.

v0.14.0 shipped 7 Nov 2026. Navigator dock (headings, page thumbnails, bookmarks, objects, search and replace), embed mode for a host iframe (?embed=1), Italian, paragraph language detection. Official README still pastes docker run -p 8080:80 ghcr.io/stffnb/edentext with no tag. GHCR's latest is 0.14.0 today. I pin ghcr.io/stffnb/edentext:v0.14.0. I do not float :latest on a hostname I will bookmark. The project is still in beta. Upstream says keep backups of documents you care about. That backup is not a Docker volume.

I am writing this as a lab log on Ubuntu 24.04. I did not stand the stack up in this Cloud Agent run. The compose, curl checks, and failure shapes come from the v0.14.0 README, the Dockerfile (nginx:alpine serving dist/), the 7 Nov release notes, GHCR tags, Self-Host Weekly 6 Nov 2026, and the experience notes on this queue item. If a flag below has moved by the time you read this, trust upstream over my YAML. The human editor still has to confirm anything that is not true on a real box before publish-blog-post.php --publish.

What I actually installed

Ubuntu 24.04 LTS, Docker Engine with the Compose v2 plugin, image ghcr.io/stffnb/edentext:v0.14.0. Project under /opt/edentext. App bound to 127.0.0.1:8080 mapped onto container port 80. Caddy on 80/443 for https://docs.example.com — replace that hostname. Nginx is the same idea: terminate TLS, proxy to localhost, do not publish 8080 on 0.0.0.0.

Official one-liner publishes "8080:80". I tighten that to loopback myself. There is no login. There is no SECURITY_ENABLELOGIN. Anyone who can reach the origin loads a full editor. Documents they type stay in their browser, not mine, but I am still donating CPU, bandwidth, and a public Word-like UI on my hostname. That is enough reason not to leave 8080 on the public IPv4.

Hardware is not the story. One container. Static files. No Postgres. No Redis. The first download is about 2 MB compressed for most languages; dictionaries and fonts load as needed. A 1 GB VPS can run this next to Caddy. I would not put it on the same weekend as an Immich import and then blame nginx.

There is no .env with secrets for the editor itself. If I add Caddy basic_auth, that hash lives in the Caddyfile at mode 600. I do not push that file to a public remote.

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg openssl ufw

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

UFW is allow-list. I do not add 8080. If something already owns 80 and 443, I reuse that edge. A second proxy fighting for those ports looks like EdenText is broken when it is not. I check ss before I write a new Caddyfile.

ss -tlnp | grep -E ':80|:443|:8080'

Where it broke

On a fresh Ubuntu 24.04 box this install is famous for a container that looks empty on purpose, and an operator who still mounts a volume anyway.

I wrote compose the way I write every other app: volumes: - ./data:/data. I opened the editor, typed a letter, closed the tab, and felt done. du -sh /opt/edentext/data stayed tiny. Recreate the container and the UI still had the letter — until I opened a private window. Empty library. Same hostname, same VPS, different browser profile. Experience notes I keep for this app say it twice: the container stores nothing; documents, settings, and recovery copies live in that browser's localStorage. Clearing site data, a private window, or opening the URL on the phone shows an empty library. Export .odt / .docx. Do not treat the VPS volume as a document backup.

# The container is healthy. The volume is the bug.
# /opt/edentext/data is empty or only nginx junk
# the letter is in the browser origin's localStorage
# a tar of /opt/edentext will not bring the library back

Official README: EdenText needs no backend and stores nothing on the server. The Dockerfile is COPY dist /usr/share/nginx/html onto nginx:alpine. There is no /data. There is no SQLite. The last three versions of the open document are also in the browser, in case one fails to load. That is a crash net for the tab, not an offsite copy.

 Where EdenText actually stores files

Caption: Documents, settings, and the last three recovery copies live in this browser on this origin. A tar of /opt/edentext backs up compose, not the library. Private window or Clear site data and the UI is empty until I open an exported .odt.

Second wall: I published "8080:80" because that is the README one-liner, and I opened http://VPS_IP:8080 from the laptop. The editor loaded. There is no first-admin wizard. There is no password. Stirling at least had a login flag I could get wrong. Here the failure is quieter: a public word processor on a port I forgot about, HTTP so PWA install never appears, and a localStorage origin of http://203.0.113.10:8080 that will not follow me when I later put Caddy in front. Switch to https://docs.example.com and the library I typed on the IP is gone. Same files, different origin. I bind 8080 to loopback. I finish the first open after Caddy has a cert.

# The container is healthy. The bind is the bug.
ports:
  - "8080:80"
# origin A: http://VPS_IP:8080
# origin B: https://docs.example.com
# localStorage does not move with DNS

Third: I put Caddy on HTTP-only “until DNS works” and opened http://docs.example.com. The ribbon loaded. Install app was missing. Save wrote a download instead of writing back to the file I had opened. Copy buttons did nothing useful. Official self-hosting section: serve HTTPS. Over plain HTTP, browsers turn off offline use, installing it as an app, saving straight back to the opened file, and the copy buttons. localhost is the exception. http://127.0.0.1:8080 over an SSH tunnel behaves like HTTPS. http://docs.example.com does not. I wasted a first hour on “the File System Access API must be broken in this image.” The image was fine. The scheme was http.

# The UI is up. The scheme is the bug.
# address bar: http://docs.example.com
# PWA / offline / save-in-place / copy buttons: off
# localhost:8080 over the tunnel: those features work

 What HTTP turns off in EdenText

Caption: localhost and public HTTPS keep PWA install, offline, File System Access save-in-place, and copy buttons. Public HTTP turns all four off. I treated “the editor loaded” as done and then wondered why Install app never appeared.

Fourth: I almost served the release zip myself with a typical SPA try_files $uri $uri/ /index.html. Official note: tabs opened before an update keep loading the files of their own version until they are reloaded. The server should answer requests for missing files with 404 rather than index.html, so those tabs can ask to be reloaded. Fall through to index.html and a hashed .js that no longer exists comes back as HTML. The tab never gets a clean 404. The official image uses stock nginx:alpine with no SPA fallback, which is the behaviour I want. I still curl a missing asset after up. If that path is 200, I have the wrong nginx config.

curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/this-file-does-not-exist.js

/ should be 200. The missing .js should be 404, not 200.

I treat those as one chain. Empty volume is first because I will lose letters and think Docker ate them. Published 8080 is second because it fails as an exposure and as a second origin. HTTP is third because the UI looks up and half the browser APIs are dead. SPA fallback is last because it only shows up the day I bump the image with a tab still open.

On a fresh Ubuntu 24.04 box this is the failure this install is famous for: compose that looks ready, a healthy nginx, and a library that was never on the disk.

 EdenText install path with the localStorage and HTTPS traps

Caption: Pin v0.14.0, bind 8080 to loopback, Caddy on 443. The red branches are HTTP (PWA and save-in-place off) and a library that only exists in this browser. The yellow branch is missing assets falling through to index.html after an image bump.

The working install

Official Docker shape for EdenText v0.14.0, then Caddy. I followed upstream for the image and the “no backend” rule, then pinned the tag and bound 8080 to loopback. If docker compose version fails after the Engine install, I stop.

1. Install Docker Engine

sudo apt-get update -qqy
sudo apt-get install ca-certificates curl -qqy
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt-get update -qqy
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin -qqy

sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

If docker compose version fails, I fix Docker before I write YAML. I log out and back in (or newgrp docker) so the socket group applies. Otherwise every compose command wants sudo and I end up with root-owned files under /opt/edentext.

2. Create the project directory and pin the image

I do not invent a backend. Official docs are one docker run. I wrap that in Compose so a reboot brings the container back, and I pin the tag GHCR showed as latest on 7 Nov 2026.

sudo mkdir -p /opt/edentext
sudo chown -R "$USER":"$USER" /opt/edentext
cd /opt/edentext
services:
  edentext:
    image: ghcr.io/stffnb/edentext:v0.14.0
    container_name: edentext
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"

No volumes for documents. No environment block. The image is nginx serving static files. A bind for ./data would only confuse the next restore. Official README also ships a release zip (edentext-v0.14.0.zip) and npm run build into dist/ if I would rather skip Docker. Same rule: the folder on disk is the app, not the library. For an update of a zip deploy, copy the new files over the old ones instead of emptying the folder first, so open tabs can still fetch their own hashed assets until they reload.

cd /opt/edentext
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=40 edentext
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/this-file-does-not-exist.js
ss -tlnp | grep 8080

8080 should be 127.0.0.1. / should be 200. The missing .js should be 404. If / is not 200, I look at docker compose logs edentext before I touch Caddy.

3. First open on loopback, then put Caddy in front

Open http://127.0.0.1:8080 over an SSH tunnel if DNS is not ready:

ssh -L 8080:127.0.0.1:8080 user@your-vps

localhost is a secure context. PWA, save-in-place, and copy buttons work on the tunnel. I still do not type anything I care about here if the production origin will be https://docs.example.com. That library will not come along.

Confirm DNS before the first TLS start. Create a Caddyfile beside Compose:

docs.example.com {
    encode gzip
    reverse_proxy 127.0.0.1:8080
}
sudo systemctl reload caddy

After the cert lands I open https://docs.example.com. I create a throwaway document. I use Save / export to .odt. I close the tab. I reopen the hostname. The throwaway should still be in Recent documents in this browser. I open a private window to the same URL. Empty library. That is the product, not a bug. I keep the .odt in the same place I keep client files — Nextcloud is still in my queue; until then it is a folder I already back up.

There is no app password. If this hostname should not be a public editor, I add Caddy basic_auth before I type real letters, because a later auth wall on the same origin still sees the same localStorage. Hash the password with caddy hash-password and keep the Caddyfile mode 600. Authentik is already a post if I want SSO later; I would not wire that on day one for a static editor.

Nginx instead of Caddy is the same bind: TLS on 443, proxy_pass http://127.0.0.1:8080, no try_files … /index.html on this vhost. The image already 404s missing files. If I unzip edentext-v0.14.0.zip and serve dist/ directly, I leave stock nginx index behaviour. I do not copy a SPA snippet from another post.

Useful to know

:latest on GHCR is v0.14.0 as of this draft. I still pin v0.14.0. Tuesday should not move the hashed JS under an open tab because I was lazy with a tag.

The official hosted copy at edentext.app counts anonymous visits with GoatCounter (EU-hosted, no cookies). The desktop app, the container image, the release zip, and a self-built dist/ contain no counter. Self-hosting is how I keep even that off my origin.

Desktop installers on the same GitHub release are unsigned Electron builds that write back to disk. This post is the VPS container. v0.14.0 embed mode (?embed=1) is AGPL-attribution territory; I am not turning this hostname into an iframe host on day one. Optional AI talks to the provider I configure, not an EdenText server — leave it off until I want text leaving the box. amd64 and arm64 images exist. This is not Stirling PDF. Stirling stays the PDF toolbox. Super Productivity is still in the queue as another browser-local app. BookStack is already a post; this is not a second wiki.

Backup, expose, next step

Compose is not a backup of the library. There is no named volume with .odt files. Lose the browser profile and the letters vanish while docker compose ps still says healthy. What I actually back up: the compose file, the Caddyfile, and every document I export to disk.

I keep a folder of exported .odt / .docx next to the rest of the client files, and I treat “Recent documents” in the ribbon as a cache. Private windows are for testing the empty state, not for drafting. Recovery copies (last three versions of the open document) are also in the browser. They help if a tab crashes. They do not help if I clear site data.

cat > /opt/edentext/backup.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
PROJECT="/opt/edentext"
BACKUP_DIR="$PROJECT/backups"
RETENTION_DAYS=14
mkdir -p "$BACKUP_DIR"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
cd "$PROJECT"
mkdir -p "$BACKUP_DIR/edentext-config-${stamp}"
cp -a docker-compose.yml "$BACKUP_DIR/edentext-config-${stamp}/"
[ -f Caddyfile ] && cp -a Caddyfile "$BACKUP_DIR/edentext-config-${stamp}/"
[ -f /etc/caddy/Caddyfile ] && cp -a /etc/caddy/Caddyfile "$BACKUP_DIR/edentext-config-${stamp}/caddy-etc.Caddyfile"
find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -mtime +"$RETENTION_DAYS" -exec rm -rf {} +
echo "[$stamp] compose/Caddy backup under $BACKUP_DIR"
echo "[$stamp] documents: export .odt/.docx from the browser; they are not in this tar"
EOF
chmod 700 /opt/edentext/backup.sh
/opt/edentext/backup.sh

Restore on a spare VM: same compose, same pin, Caddy on the same hostname. A new hostname is a new origin. The old localStorage will not appear. I copy the exported .odt files to the laptop, open the new URL, and File → Open. If login-looking emptiness is actually a new origin, I did not lose Docker. I changed the URL.

Upgrades after that config copy:

cd /opt/edentext
/opt/edentext/backup.sh
# Edit the image tag in docker-compose.yml, then:
docker compose pull
docker compose up -d
docker compose logs --tail=40 edentext
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/this-file-does-not-exist.js

If I floated :latest and a Tuesday image moves 0.14 to 0.15 under the same word, open tabs keep requesting old hashed files. Official advice: do not empty the folder first on a zip deploy; let missing files 404 so the tab can ask for a reload. On Docker, the new image replaces the files in one shot. I reload the tab after up. I do not debug “Unexpected token '<'” as a JS bug until that missing-asset curl is 404.

What I have running now, on paper for this lab, is EdenText v0.14.0 on loopback 8080, Caddy on 443 for https://docs.example.com, no document volume, a compose/Caddy backup script, and a habit of exporting .odt the same day I type anything I would miss. Next I open one real letter on that hostname, I confirm a private window is empty, I keep 8080 off ss on 0.0.0.0, and I leave embed mode and the AI assistant until that export path is boring. Stirling stays the PDF toolbox. This is the Word-like editor. The VPS still does not hold the library.

Did you hit the same wall?

I got stuck on a healthy nginx and a tar of /opt/edentext that never contained the letter — the library was localStorage in that browser. Did you hit the same thing, or a different one — 8080 on the public IPv4 so the later HTTPS origin looked empty, PWA and save-in-place dead on plain HTTP, missing .js falling through to index.html after an image bump? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.