feat(packaging): add Debian (.deb) build via nfpm with systemd unit (v2)#7583
feat(packaging): add Debian (.deb) build via nfpm with systemd unit (v2)#7583JohnMcLear wants to merge 1 commit intoether:developfrom
Conversation
First-class Debian packaging for Etherpad, producing etherpad_<version>_<arch>.deb artefacts for amd64 and arm64 from a single nfpm manifest. Installing the package gives users: - /opt/etherpad with a prebuilt, self-contained node_modules/ — no pnpm required at runtime, just `nodejs (>= 20)`. - etherpad system user/group, created via `adduser` in preinst. - /etc/etherpad/settings.json seeded from the template on first install, preserved across upgrades, removed on `purge`. Seed rewrites dbType from the template's dev-only `dirty` default to `sqlite`, pointed at /var/lib/etherpad/etherpad.db so fresh installs get an ACID-safe DB without manual config. sqlite is shipped by ueberdb2 (rusty-store-kv), so no additional apt deps are needed. - /var/lib/etherpad owned by etherpad:etherpad, writable under the hardened unit's ProtectSystem=strict. - /lib/systemd/system/etherpad.service — hardened unit (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, RestrictAddressFamilies) with Restart=on-failure. - /usr/bin/etherpad CLI wrapper running `node --import tsx/esm`. CI (.github/workflows/deb-package.yml) triggers on v* tags, builds both arches via native runners (ubuntu-latest + ubuntu-24.04-arm), smoke-tests the amd64 package end-to-end (install → verify sqlite default → systemctl start → curl /health → purge → confirm user removed), and attaches the artefacts to the GitHub Release. Re-introduces the work from ether#7559 (reverted in ether#7582) with two corrections: 1. Package name and all installed paths use `etherpad`, not `etherpad-lite` — matches the repo rename. Kept replaces/conflicts on `etherpad-lite` so any dev builds of the reverted PR upgrade cleanly. 2. Default dbType is `sqlite`, not `dirty`. The template's own comment says dirty is for testing only; shipping it by default to everyone who runs `apt install etherpad` is the wrong tradeoff for a production package. Publishing to an APT repo (Cloudsmith, Launchpad PPA, self-hosted reprepro) is intentionally out of scope — needs a governance decision on who holds the signing key. Recipes are documented in packaging/README.md. Refs ether#7529, ether#7559, ether#7582 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/review |
Code Review by Qodo
1. Plugin migration startup failure
|
Review Summary by QodoAdd Debian (.deb) packaging with systemd service and nfpm manifest
WalkthroughsDescription• Add native Debian (.deb) packaging via nfpm for amd64 and arm64 • Configure systemd service with hardened security settings and auto-restart • Implement pre/post install/remove scripts for user/group and config management • Default database to sqlite instead of dev-only dirty mode for production safety • Add CI workflow to build, smoke-test, and attach packages to GitHub releases Diagramflowchart LR
A["Source code<br/>+ node_modules"] -->|Stage| B["staging/opt/etherpad"]
B -->|nfpm manifest| C["nfpm.yaml"]
C -->|Build| D["etherpad_VERSION_ARCH.deb"]
E["preinstall.sh"] -->|Create user| F["etherpad system user"]
G["postinstall.sh"] -->|Configure| H["Settings + Symlinks"]
I["etherpad.service"] -->|Hardened unit| J["systemd service"]
K["CI workflow"] -->|Tag trigger| L["Build + Test + Release"]
File Changes1. packaging/scripts/preinstall.sh
|
Code Review by Qodo
1. Readonly /opt breaks startup
|
| # --- Sandboxing --------------------------------------------------------- | ||
| NoNewPrivileges=true | ||
| ProtectSystem=strict | ||
| ProtectHome=true | ||
| PrivateTmp=true | ||
| PrivateDevices=true | ||
| ProtectKernelTunables=true | ||
| ProtectKernelModules=true | ||
| ProtectKernelLogs=true | ||
| ProtectControlGroups=true | ||
| ProtectHostname=true | ||
| ProtectClock=true | ||
| RestrictRealtime=true | ||
| RestrictSUIDSGID=true | ||
| RestrictNamespaces=true | ||
| LockPersonality=true | ||
| MemoryDenyWriteExecute=false # Node's JIT needs W+X mappings | ||
| SystemCallArchitectures=native | ||
| RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK | ||
| UMask=0027 | ||
|
|
||
| ReadWritePaths=/var/lib/etherpad /var/log/etherpad /etc/etherpad | ||
|
|
There was a problem hiding this comment.
1. Readonly /opt breaks startup 🐞 Bug ☼ Reliability
The unit sets ProtectSystem=strict but Etherpad writes runtime files under settings.root/var during startup (for example var/js and var/installed_plugins.json). Because /opt/etherpad/var is neither created/redirected in postinstall nor included in ReadWritePaths, a fresh install can fail to start with mkdir/write errors under /opt/etherpad/var.
Agent Prompt
### Issue description
The systemd unit makes `/opt/etherpad` effectively read-only (`ProtectSystem=strict` + `ReadWritePaths` omits `/opt/etherpad/...`), but Etherpad writes to `path.join(settings.root, 'var', ...)` during startup, where `settings.root` resolves to `/opt/etherpad` in this package layout. This can prevent `etherpad.service` from starting.
### Issue Context
Etherpad startup awaits `checkForMigration()` which can write `${root}/var/installed_plugins.json`, and the express hooks create `${root}/var/js`. The package currently does not create or redirect `/opt/etherpad/var` to a writable location.
### Fix Focus Areas
- packaging/scripts/postinstall.sh[34-40]
- packaging/systemd/etherpad.service[22-44]
### Suggested fix
Implement one of the following (prefer the symlink approach to keep writes out of `/opt`):
1) **Symlink `/opt/etherpad/var` to a writable location**:
- In `postinstall.sh` (configure):
- `mkdir -p /var/lib/etherpad/var`
- `ln -sfn /var/lib/etherpad/var /opt/etherpad/var`
- `chown -R etherpad:etherpad /var/lib/etherpad/var`
- This keeps `ReadWritePaths=/var/lib/etherpad` sufficient.
2) **Allow `/opt/etherpad/var` writes explicitly**:
- Create `/opt/etherpad/var` in `postinstall.sh` and `chown` it to `etherpad:etherpad`.
- Add `/opt/etherpad/var` to `ReadWritePaths` in the unit.
Either way, ensure the directory exists before service start so `fs.mkdirSync(.../var/js)` and plugin migration writes don’t throw.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| - name: Stage tree for packaging | ||
| run: | | ||
| set -eux | ||
| STAGE=staging/opt/etherpad | ||
| mkdir -p "${STAGE}" | ||
|
|
||
| # Production footprint = src/ + bin/ + node_modules/ + metadata. | ||
| cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE \ | ||
| node_modules "${STAGE}/" | ||
|
|
||
| # Make pnpm-workspace.yaml production-only (same trick Dockerfile uses). | ||
| printf 'packages:\n - src\n - bin\n' > "${STAGE}/pnpm-workspace.yaml" | ||
|
|
There was a problem hiding this comment.
1. Plugin migration startup failure 🐞 Bug ☼ Reliability
On fresh installs, Etherpad startup runs checkForMigration(), which falls back to running pnpm ls and then writing /opt/etherpad/var/installed_plugins.json if the file is missing; the package does not ship/create that file, and the systemd unit disallows writes under /opt/etherpad. This can cause the service to crash during startup and never reach /health.
Agent Prompt
### Issue description
Fresh installs can fail because Etherpad calls `checkForMigration()` during startup, and if `/opt/etherpad/var/installed_plugins.json` is missing it tries to execute `pnpm ls` and write that file under `/opt/etherpad/var`. The .deb packaging currently doesn’t ship/create that file (or even `/opt/etherpad/var`), and the systemd unit doesn’t permit writes under `/opt/etherpad`.
### Issue Context
- Packaging stages `/opt/etherpad` from a curated file list.
- Etherpad’s plugin migration logic treats absence of `var/installed_plugins.json` as a trigger to run `pnpm` and then persist plugin state under `settings.root/var`.
- The systemd sandbox only allows writes to `/var/lib/etherpad`, `/var/log/etherpad`, and `/etc/etherpad`.
### Fix Focus Areas
- .github/workflows/deb-package.yml[73-85]
- packaging/nfpm.yaml[47-59]
- packaging/scripts/postinstall.sh[14-40]
- packaging/systemd/etherpad.service[22-44]
### Suggested fix approach
1. Ensure `/opt/etherpad/var/` exists in the packaged tree (or create it in `postinstall.sh`).
2. Seed `/opt/etherpad/var/installed_plugins.json` at install time with minimal valid content (for example, only `ep_etherpad-lite`), so `checkForMigration()` does not attempt to run `pnpm` on first boot.
3. If you want runtime plugin install/uninstall to work under the hardened unit, relocate plugin state to `/var/lib/etherpad` (and symlink `/opt/etherpad/var` to it), and/or extend `ReadWritePaths` to include the required writable plugin directories.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Summary
Reintroduces the Debian packaging from #7559 (reverted in #7582) with two targeted fixes flagged during review:
etherpad-lite→etherpadeverywhere — package name, installed paths (/opt/etherpad,/etc/etherpad,/var/lib/etherpad,/var/log/etherpad), systemd unit (etherpad.service), CLI wrapper (/usr/bin/etherpad),Documentation=URL, and the.debfilename (etherpad_<ver>_<arch>.deb). Matches the repo rename fromether/etherpad-litetoether/etherpad. Keptreplaces/conflicts/provides: etherpad-liteso any dev builds of the reverted 7559 artefact upgrade cleanly.sqlite(wasdirty). The shippedsettings.json.templatehas always warned "You shouldn't use 'dirty' for anything else than testing", and a Debian package is exactly the "not testing" case.postinstallrewrites the seeded/etc/etherpad/settings.jsontodbType: "sqlite"pointed at/var/lib/etherpad/etherpad.db. sqlite is already a transitive dep viaueberdb2→rusty-store-kv, so no new apt dependencies.Everything else matches the post-review state of #7559 (smoke-test exits non-zero on
/healthtimeout,/etc/default/etherpadis0640 root:etherpad, 2-space indent).Installing the package gives users:
/opt/etherpadwith a prebuilt, self-containednode_modules/— no pnpm required at runtime, onlynodejs (>= 20).etherpadsystem user/group, created viaadduserinpreinst./etc/etherpad/settings.json(seeded from the template on first install withsqlitedefault; preserved across upgrades; removed onpurge)./var/lib/etherpadowned byetherpad:etherpad, writable underProtectSystem=strict./lib/systemd/system/etherpad.service— hardened unit (NoNewPrivileges,ProtectSystem=strict,ProtectHome,PrivateTmp,RestrictAddressFamilies) withRestart=on-failure./usr/bin/etherpadCLI wrapper runningnode --import tsx/esm.Part of #7529 — top-3 deployment targets (Snap #7558, Apt, Home Assistant).
CI
.github/workflows/deb-package.ymltriggers onv*tags, builds both arches via native runners (ubuntu-latest+ubuntu-24.04-arm), and smoke-tests the amd64 package end-to-end:dpkg -iinstalls/etc/etherpad/settings.jsoncontains"dbType": "sqlite"(guards against accidental dirty-default regressions)systemctl start etherpadcurl /healthreturns 200 (exits non-zero + dumpsjournalctlif it never becomes healthy)dpkg --purgeremoves config + userArtefacts are attached to the GitHub Release.
Not included (follow-up)
Publishing to an APT repo (Cloudsmith / Launchpad PPA / self-hosted reprepro) is out of scope — needs a governance decision on who holds the signing key. Recipes are in
packaging/README.mdto be wired in once that's decided.Legacy
bin/buildDebian.shandbin/deb-src/are stale (Etherpad v1.3, init-based, unmaintained). Flagged for removal in a follow-up PR so this one stays mechanical.Test plan
pnpm install --frozen-lockfile && pnpm run build:etherpadsucceedsnfpm package --packager debproduces a well-formed.deb(dpkg-deb -I/-c)/healthreturns OK,/etc/etherpad/settings.jsonshows"dbType": "sqlite",/var/lib/etherpad/etherpad.dbis created by the etherpad user/etc/etherpad/settings.jsonuntouched; service restartedapt removekeeps/etcand/var/lib;apt purgeremoves them plus theetherpaduserRefs #7529, #7559, #7582
🤖 Generated with Claude Code