feat(packaging): add Debian (.deb) build via nfpm with systemd unit (#7559)

* feat(packaging): add Debian (.deb) build via nfpm with systemd unit

First-class Debian packaging for Etherpad, producing signed-ready
etherpad-lite_<version>_<arch>.deb artefacts for amd64 and arm64 from a
single nfpm manifest. Installing the package gives users:

- /opt/etherpad-lite 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-lite/settings.json seeded from the template on first
  install, preserved across upgrades, removed on `purge`.
- /var/lib/etherpad-lite owned by etherpad:etherpad, with the default
  dirty-DB retargeted there so ProtectSystem=strict works.
- /lib/systemd/system/etherpad-lite.service — hardened unit
  (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp,
  RestrictAddressFamilies) with Restart=on-failure.
- /usr/bin/etherpad-lite 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 → systemctl start → curl /health
→ purge → confirm user removed), and attaches the artefacts to the
GitHub Release.

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 #7529

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(deb): fail smoke test on /health timeout, tighten default-file perms, 2-space indent

Addresses Qodo review feedback on #7559:

1. Smoke test false-positive: the `for` loop polling /health never failed
   the job if the endpoint stayed down — `curl && break || sleep 2`
   keeps returning 0 from the trailing `sleep`, so `set -e` never
   trips. CI could attach a broken .deb to a release. Fix: track
   success explicitly and exit 1 (plus dump journald logs for
   diagnostics) when the service never becomes healthy.

2. /etc/default/etherpad-lite was world-readable (0644). systemd loads
   it via `EnvironmentFile=…`, and Etherpad supports
   ${ENV_VAR}-substitution for secrets (DB_PASSWORD etc.), so any
   local user could read anything admins drop there. Fix: install the
   conffile as root:etherpad 0640 — only root and the service user can
   read it.

3. Indentation: reflow maintainer scripts from 4-space to 2-space to
   match the repo style rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-04-22 18:45:50 +01:00 committed by GitHub
parent bd762a2fda
commit 6bb879ed03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 599 additions and 0 deletions

161
.github/workflows/deb-package.yml vendored Normal file
View File

@ -0,0 +1,161 @@
name: Debian package
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
inputs:
ref:
description: 'Git ref to package (defaults to current)'
required: false
permissions:
contents: write # attach release assets
env:
NFPM_VERSION: v2.43.0
jobs:
build:
name: Build .deb (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Resolve version
id: v
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="$(node -p "require('./package.json').version")"
fi
echo "version=${VERSION}" >>"$GITHUB_OUTPUT"
echo "Packaging version: ${VERSION}"
- uses: pnpm/action-setup@v6
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build UI + admin
run: pnpm run build:etherpad
- name: Install nfpm
run: |
set -e
NFPM_ARCH=amd64
[ "${{ matrix.arch }}" = "arm64" ] && NFPM_ARCH=arm64
curl -fsSL -o /tmp/nfpm.deb \
"https://github.com/goreleaser/nfpm/releases/download/${NFPM_VERSION}/nfpm_${NFPM_VERSION#v}_${NFPM_ARCH}.deb"
sudo dpkg -i /tmp/nfpm.deb
- name: Stage tree for packaging
run: |
set -eux
STAGE=staging/opt/etherpad-lite
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"
mkdir -p packaging/etc
cp settings.json.template packaging/etc/settings.json.dist
# Purge test fixtures and dev caches from node_modules to shrink size.
find "${STAGE}/node_modules" -type d \
\( -name test -o -name tests -o -name '__tests__' \
-o -name example -o -name examples -o -name docs \) \
-prune -exec rm -rf {} + 2>/dev/null || true
find "${STAGE}/node_modules" -type f \
\( -name '*.md' -o -name '*.ts.map' -o -name '*.map' \
-o -name 'CHANGELOG*' -o -name 'HISTORY*' \) \
-delete 2>/dev/null || true
- name: Build .deb
env:
VERSION: ${{ steps.v.outputs.version }}
ARCH: ${{ matrix.arch }}
run: |
mkdir -p dist
nfpm package --packager deb -f packaging/nfpm.yaml --target dist/
- name: Smoke-test the package (amd64 only)
if: matrix.arch == 'amd64'
run: |
set -eux
sudo apt-get update
sudo apt-get install -y nodejs
sudo dpkg -i dist/*.deb || sudo apt-get install -f -y
test -x /usr/bin/etherpad-lite
test -f /etc/etherpad-lite/settings.json
test -L /opt/etherpad-lite/settings.json
id etherpad
systemctl cat etherpad-lite.service
sudo systemctl start etherpad-lite
ok=
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:9001/health; then
ok=1
break
fi
sleep 2
done
if [ -z "${ok}" ]; then
# Attach logs so the failing run is diagnosable.
sudo journalctl -u etherpad-lite --no-pager -n 200 || true
exit 1
fi
sudo systemctl stop etherpad-lite
sudo dpkg --purge etherpad-lite
! id etherpad 2>/dev/null
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: etherpad-lite-${{ steps.v.outputs.version }}-${{ matrix.arch }}-deb
path: dist/*.deb
if-no-files-found: error
release:
name: Attach to GitHub Release
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
pattern: etherpad-lite-*-deb
merge-multiple: true
- name: Attach .deb files to release
uses: softprops/action-gh-release@v2
with:
files: dist/*.deb
fail_on_unmatched_files: true

89
packaging/README.md Normal file
View File

@ -0,0 +1,89 @@
# Etherpad Debian / RPM packaging
Produces native `.deb` (and, with the same manifest, `.rpm` / `.apk`)
packages for Etherpad using [nfpm](https://nfpm.goreleaser.com).
## Layout
```
packaging/
nfpm.yaml # nfpm package manifest
bin/etherpad-lite # /usr/bin launcher
scripts/ # preinst / postinst / prerm / postrm
systemd/etherpad-lite.service
systemd/etherpad-lite.default
etc/settings.json.dist # populated in CI from settings.json.template
```
Built artefacts land in `./dist/`.
## Building locally
Prereqs: Node 22, pnpm 10+, nfpm.
```sh
pnpm install --frozen-lockfile
pnpm run build:etherpad
# Stage the tree the way CI does:
STAGE=staging/opt/etherpad-lite
mkdir -p "$STAGE"
cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE \
node_modules "$STAGE/"
printf 'packages:\n - src\n - bin\n' > "$STAGE/pnpm-workspace.yaml"
cp settings.json.template packaging/etc/settings.json.dist
VERSION=$(node -p "require('./package.json').version") \
ARCH=amd64 \
nfpm package --packager deb -f packaging/nfpm.yaml --target dist/
```
## Installing
```sh
sudo apt install ./dist/etherpad-lite_2.6.1_amd64.deb
sudo systemctl start etherpad-lite
curl http://localhost:9001/health
```
`apt` will pull in `nodejs (>= 20)`; on Ubuntu 22.04 add NodeSource first:
```sh
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
```
## Configuration
- Edit `/etc/etherpad-lite/settings.json`, then
`sudo systemctl restart etherpad-lite`.
- Environment overrides: `/etc/default/etherpad-lite`.
- Logs: `journalctl -u etherpad-lite -f`.
- Data (dirty-DB default): `/var/lib/etherpad-lite/`.
## Upgrading
`dpkg --install etherpad-lite_<new>.deb` (or `apt install`) replaces the
app tree under `/opt/etherpad-lite` while preserving
`/etc/etherpad-lite/*` and `/var/lib/etherpad-lite/*`. The service is
restarted automatically.
## Removing
- `sudo apt remove etherpad-lite` — keeps config and data.
- `sudo apt purge etherpad-lite` — also removes config, data, and the
`etherpad` system user.
## Publishing to an APT repository (follow-up)
Out of scope here — requires credentials and ownership decisions.
Recipes once a repo is picked:
- **Cloudsmith** (easiest, free OSS tier):
`cloudsmith push deb ether/etherpad-lite/any-distro/any-version dist/*.deb`
- **Launchpad PPA**: requires signed source packages (a `debian/` tree),
which nfpm does not produce — use `debuild` separately.
- **Self-hosted reprepro**:
`reprepro -b /srv/apt includedeb stable dist/*.deb`
Wire the chosen option into `.github/workflows/deb-package.yml` after
the `release` job.

18
packaging/bin/etherpad-lite Executable file
View File

@ -0,0 +1,18 @@
#!/bin/sh
# /usr/bin/etherpad-lite - thin wrapper that runs Etherpad in production mode.
# Invoked by the etherpad-lite.service systemd unit.
set -e
APP_DIR="${ETHERPAD_DIR:-/opt/etherpad-lite}"
cd "${APP_DIR}"
: "${NODE_ENV:=production}"
export NODE_ENV
export ETHERPAD_PRODUCTION=true
# Run the server through tsx's ESM loader (shipped in node_modules).
# No pnpm needed at runtime.
exec node \
--import "file://${APP_DIR}/src/node_modules/tsx/dist/esm/index.mjs" \
"${APP_DIR}/src/node/server.ts" \
"$@"

117
packaging/nfpm.yaml Normal file
View File

@ -0,0 +1,117 @@
# nfpm configuration for etherpad-lite Debian/RPM/APK packages.
# Build with: nfpm package --packager deb --target dist/
# See: https://nfpm.goreleaser.com/configuration/
name: etherpad-lite
arch: ${ARCH} # amd64 | arm64 (exported by CI)
platform: linux
version: ${VERSION} # e.g. 2.6.1, stripped of leading "v"
version_schema: semver
release: "1"
section: web
priority: optional
maintainer: "Etherpad Foundation <contact@etherpad.org>"
description: |
Etherpad is a real-time collaborative editor for the web.
This package installs Etherpad as a systemd service running
from /opt/etherpad-lite with configuration in /etc/etherpad-lite.
vendor: "Etherpad Foundation"
homepage: https://etherpad.org
license: Apache-2.0
depends:
- nodejs (>= 20)
- adduser
- ca-certificates
recommends:
- libreoffice # enables DOC/DOCX/PDF/ODT export
- curl
suggests:
- postgresql-client
- mariadb-client
conflicts:
- etherpad # legacy bin/buildDebian.sh package name
replaces:
- etherpad
provides:
- etherpad
# ---------------------------------------------------------------------------
# Contents. staging/ is populated by CI before invoking nfpm:
# staging/opt/etherpad-lite/ -- source + node_modules + built assets
# ---------------------------------------------------------------------------
contents:
- src: ./staging/opt/etherpad-lite
dst: /opt/etherpad-lite
type: tree
file_info:
mode: 0755
owner: root
group: root
- src: ./packaging/systemd/etherpad-lite.service
dst: /lib/systemd/system/etherpad-lite.service
file_info:
mode: 0644
# Default environment file (conffile: preserved on upgrade).
# Mode 0640 + group=etherpad so passwords/secrets admins drop in here
# are only readable by root and the etherpad service user — /etc/default
# is world-readable by default (0644), which would leak DB creds etc.
- src: ./packaging/systemd/etherpad-lite.default
dst: /etc/default/etherpad-lite
type: config|noreplace
file_info:
mode: 0640
owner: root
group: etherpad
- src: ./packaging/bin/etherpad-lite
dst: /usr/bin/etherpad-lite
file_info:
mode: 0755
# Template used by postinstall to seed /etc/etherpad-lite/settings.json.
# Intentionally NOT a conffile: postinstall creates the real settings.json
# once on first install and never touches it again, so upgrades don't
# prompt with dpkg merge dialogs.
- src: ./packaging/etc/settings.json.dist
dst: /usr/share/etherpad-lite/settings.json.dist
file_info:
mode: 0644
- dst: /etc/etherpad-lite
type: dir
file_info:
mode: 0755
- dst: /var/lib/etherpad-lite
type: dir
file_info:
mode: 0750
- dst: /var/log/etherpad-lite
type: dir
file_info:
mode: 0750
scripts:
preinstall: ./packaging/scripts/preinstall.sh
postinstall: ./packaging/scripts/postinstall.sh
preremove: ./packaging/scripts/preremove.sh
postremove: ./packaging/scripts/postremove.sh
overrides:
deb:
depends:
- nodejs (>= 20)
- adduser
- ca-certificates
rpm:
depends:
- nodejs >= 20
- shadow-utils
- ca-certificates

View File

@ -0,0 +1,68 @@
#!/bin/sh
# postinstall - runs after files have been unpacked.
# Debian actions: configure | abort-upgrade | abort-remove | abort-deconfigure
set -e
ETC_DIR=/etc/etherpad-lite
VAR_DIR=/var/lib/etherpad-lite
LOG_DIR=/var/log/etherpad-lite
APP_DIR=/opt/etherpad-lite
DIST_SETTINGS=/usr/share/etherpad-lite/settings.json.dist
ACTIVE_SETTINGS="${ETC_DIR}/settings.json"
case "$1" in
configure)
mkdir -p "${ETC_DIR}" "${VAR_DIR}" "${LOG_DIR}"
chown root:etherpad "${ETC_DIR}"
chmod 0750 "${ETC_DIR}"
chown etherpad:etherpad "${VAR_DIR}" "${LOG_DIR}"
chmod 0750 "${VAR_DIR}" "${LOG_DIR}"
if [ ! -e "${ACTIVE_SETTINGS}" ]; then
cp "${DIST_SETTINGS}" "${ACTIVE_SETTINGS}"
# Point the default dirty-DB at /var/lib so ProtectSystem=strict works.
sed -i \
's|"filename": "var/dirty.db"|"filename": "/var/lib/etherpad-lite/dirty.db"|' \
"${ACTIVE_SETTINGS}"
chown root:etherpad "${ACTIVE_SETTINGS}"
chmod 0640 "${ACTIVE_SETTINGS}"
fi
# Etherpad reads settings.json from CWD (/opt/etherpad-lite). Expose
# the /etc copy there via symlink.
ln -sfn "${ACTIVE_SETTINGS}" "${APP_DIR}/settings.json"
if [ -d "${APP_DIR}/var" ]; then
chown -R etherpad:etherpad "${APP_DIR}/var" || true
fi
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
# Enable on first install; leave state alone on upgrade.
if [ -z "$2" ]; then
systemctl enable etherpad-lite.service >/dev/null 2>&1 || true
fi
# Restart on upgrade to pick up new code (skip on fresh install --
# admin may want to configure first).
if [ -n "$2" ]; then
systemctl try-restart etherpad-lite.service >/dev/null 2>&1 || true
fi
fi
cat <<EOF
Etherpad installed. Edit /etc/etherpad-lite/settings.json, then:
sudo systemctl start etherpad-lite
Default port 9001. Service logs: journalctl -u etherpad-lite -f
EOF
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinstall called with unknown argument: $1" >&2
exit 1
;;
esac
exit 0

43
packaging/scripts/postremove.sh Executable file
View File

@ -0,0 +1,43 @@
#!/bin/sh
# postremove - runs after files are removed.
# Debian actions: remove | purge | upgrade | failed-upgrade | abort-install |
# abort-upgrade | disappear
set -e
APP_DIR=/opt/etherpad-lite
case "$1" in
remove)
[ -L "${APP_DIR}/settings.json" ] && rm -f "${APP_DIR}/settings.json" || true
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
fi
;;
purge)
rm -rf /etc/etherpad-lite
rm -rf /var/lib/etherpad-lite
rm -rf /var/log/etherpad-lite
if getent passwd etherpad >/dev/null 2>&1; then
deluser --system etherpad >/dev/null 2>&1 || true
fi
if getent group etherpad >/dev/null 2>&1; then
delgroup --system etherpad >/dev/null 2>&1 || true
fi
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
fi
;;
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
*)
echo "postremove called with unknown argument: $1" >&2
exit 1
;;
esac
exit 0

28
packaging/scripts/preinstall.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/sh
# preinstall - runs before files are unpacked.
# Debian actions: install | upgrade | abort-upgrade
set -e
case "$1" in
install|upgrade)
if ! getent group etherpad >/dev/null 2>&1; then
addgroup --system etherpad
fi
if ! getent passwd etherpad >/dev/null 2>&1; then
adduser --system --ingroup etherpad \
--home /var/lib/etherpad-lite \
--no-create-home \
--shell /usr/sbin/nologin \
--gecos "Etherpad service user" \
etherpad
fi
;;
abort-upgrade)
;;
*)
echo "preinstall called with unknown argument: $1" >&2
exit 1
;;
esac
exit 0

20
packaging/scripts/preremove.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/sh
# preremove - runs before files are removed.
# Debian actions: remove | upgrade | deconfigure | failed-upgrade
set -e
case "$1" in
remove|upgrade|deconfigure)
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
systemctl stop etherpad-lite.service >/dev/null 2>&1 || true
fi
;;
failed-upgrade)
;;
*)
echo "preremove called with unknown argument: $1" >&2
exit 1
;;
esac
exit 0

View File

@ -0,0 +1,7 @@
# /etc/default/etherpad-lite
# Environment overrides for the etherpad-lite systemd service.
# Any variable referenced by ${VAR:default} in settings.json can be set here.
NODE_ENV=production
# PORT=9001
# NODE_OPTIONS=--max-old-space-size=2048

View File

@ -0,0 +1,48 @@
[Unit]
Description=Etherpad - real-time collaborative editor
Documentation=https://etherpad.org https://github.com/ether/etherpad-lite
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=etherpad
Group=etherpad
WorkingDirectory=/opt/etherpad-lite
EnvironmentFile=-/etc/default/etherpad-lite
ExecStart=/usr/bin/etherpad-lite
Restart=on-failure
RestartSec=5s
TimeoutStopSec=20s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=etherpad-lite
# --- 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-lite /var/log/etherpad-lite /etc/etherpad-lite
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target